From 11e448267d18fb862d32bff1734e9698648422da Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 25 Sep 2023 18:12:47 +0100 Subject: [PATCH 001/294] website: update post, downloads page --- ...pp-local-file-encryption-directory-service.md | 2 ++ docs/DOWNLOADS.md | 16 ++++++++-------- .../src/_includes/blog_previews/20230925.html | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md index 2aa8a7bc0c..acbb703174 100644 --- a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md +++ b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md @@ -50,6 +50,8 @@ Other limitations of the desktop app: - you cannot send voice messages. - there is no support for calls yet. +You can download the desktop app for Linux and Mac via [downloads page](https://simplex.chat/downloads). Windows version will be available soon. + ## Group directory service and other group improvements diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 83fa18f97d..810fd6f4c1 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -7,7 +7,7 @@ revision: 20.09.2023 | Updated 20.09.2023 | Languages: EN | # Download SimpleX apps -The latest version is v5.3. +The latest version is v5.3.1. - [desktop](#desktop-app) - [mobile](#mobile-apps) @@ -19,24 +19,24 @@ The latest version is v5.3. Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Windows**: coming soon. ## Mobile apps -**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084) (v5.2.3), [TestFlight](https://testflight.apple.com/join/DWuT2LQu) (v5.3-beta.9). +**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-windows-x86-64). diff --git a/website/src/_includes/blog_previews/20230925.html b/website/src/_includes/blog_previews/20230925.html index 9fb062639e..fa05fa72ca 100644 --- a/website/src/_includes/blog_previews/20230925.html +++ b/website/src/_includes/blog_previews/20230925.html @@ -2,10 +2,10 @@ From 50d624ef6b248bce0b4cfed820e2a66cf272a400 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 25 Sep 2023 20:09:08 +0100 Subject: [PATCH 002/294] blog: move image --- ...-local-file-encryption-directory-service.md | 4 ++-- blog/images/simplex-desktop-light.png | Bin 0 -> 558338 bytes 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 blog/images/simplex-desktop-light.png diff --git a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md index acbb703174..8928e9724d 100644 --- a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md +++ b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md @@ -2,7 +2,7 @@ layout: layouts/article.html title: "SimpleX Chat v5.3 released: desktop app, local file encryption and improved groups with directory service" date: 2023-09-25 -image: /docs/images/simplex-desktop-light.png +image: images/simplex-desktop-light.png imageWide: true previewBody: blog_previews/20230925.html permalink: "/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html" @@ -36,7 +36,7 @@ Also, we added 6 new interface languages: Arabic*, Bulgarian, Finnish ## Multiplatform desktop app - + Thanks a lot to everybody who was testing the desktop app since July – it really helped to make it stable! diff --git a/blog/images/simplex-desktop-light.png b/blog/images/simplex-desktop-light.png new file mode 100644 index 0000000000000000000000000000000000000000..d28b7b88ca4c1850a71ac81d515156f469600b96 GIT binary patch literal 558338 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 z zL`aK$eL8VjWgY7zqOcNVKQX|oQoIvINgd{Yk$`~3$ag%LmLJzTej8=BxwSMOw_Yat z!W7NFpzw=O^2c0K$ZoUQwwO>HE>bMbcRGi%L!9`$Va4i2BB?3;e$(AB^!kWxt+^6a z*~A~{xFVFc0S$Q+?*{-4OaV9y3dr2J?R4+Mej)TUY)gL)ZiRhT{FtT5YonTkLb9SX zQqN(HhH>7fS`AVd8Jk;JaWGs}80sunIY<&mS`kY(&gH{TC_@4*9;zfUll8sk4UQw|!4>>bF?BhL;A zDG~d}qQk0$be(=0`N8Rlvqv=YUFq%{bBx;tvhwHU8`9oVWJ%K)I$Xa91znrnd{&B( z#{}}$akn@}F)JClTTPn;&%vYyVZFu>LyIqDB{i*D3>)CA)d784B0wV!d-2lr$kr>f zZvNg(hl!RzkSEtuDV9R^Rq^`-5rHU>pSr!lS@kBKdEqjP#KU|7R0Sijp2bD~+D83( zRzmclyYIob?!3#m{O!>+RRg3c+AU6c?eNyE)^&b&{wO@H^>0gjjre z%`F2Q)w4ssrrRtv`@&*Si+Do8%gq{xN8aLdQCo;1W!SO>?~GfGH6dwQj;FB%d!_MZ zd|M5*TwMy(W^=qkX7J)M!p9~xhUcK;!Avt?M=1S9R`B3~8XtHh^z@=NKV-S;@NvqK z6tAeXJT(Hfh&#e_Om$WVIJ%S^pTiH zFz-g6oDh%LnY*kpp%Zd~`@=Dd=rHa~ysv#OI_!6uc|q49 zz<3To@w5(FJPzVWAlRr5NX(GuEj;r8V^Qn=>$a^Fiaw7>}b zuFMO!|IJ*^!h`Ay&o2a$+>olh4+wm@%9;pNHSO+86T3v6XDDT8UFw0d_B(AfzQm9a+t z25Wk`%b0w$I$qNDV4gxDD07~hwC6+te-?ZYgtfy}f64_BYOorE zLHj<{s2N`)#{ar!-5b@_Oh@CE@#=n}>6pnGDQUuNKe?in5?r4c0)RPjk^hIg|tsqyv4p_}dT7%U<{$?E1 zWj&HU@%t>&fv@@-jbNpK?E+exh@HFUsX_g#J?(IhC9*1KUW-x`gl|#sJ1;G_>kw?0 zKT8_o2rls0%ge1&j`Zt6BFh(^^1rsk01~7vXZ&NAu|eP%mLT)+?7VD42D$HPIVvu1 ztB4JL5!uArrI8U0t$1p%q-egbq$FZ5ANI6W@C@LFp(ndG;VDYgah=zO#kiBSL0Ob~ zmix>AghYb#o|u3XM{kM&+ErX2{!K^#H26j1KXt@EU^5X~FkLmBiA$!HM!2VTv22kY4N}{s+$A`$r}dlZ?ukP>laE^rviAJw5*&g= zI3({>Eo49@~1so)M)Lan00z^I{m?+y!ND^TPf<(5^8!7OxVLz7Pd=;rv|K5Rtzh6g&2KgF% zcmO!iIYJN`U0xk$y<&L;yQ4`(z}!YO4|`Mz;?G`qj`-dib)4u|rD6RoqQk)_Xc00n z;0&dWg`X(?q$ErjtA7U_Gm!;)u)peUcFh2GzHj4G`TJMZ1E%&=hN-~!AweJuV1S4T z@-_lvsbIh&-et=0DT>BKwz00GSD#d!U91ijs2!f*YWV0r<{pP8!@HqPtenW zBOS?;q4@$XYQF%(3e%gzS+a$IEfR5A#P=JtwST?S6Wm}S!s94on*=W$USsK; zENrHe8!7!L7QHxsQoObR=o}+K zX|_=PJ`#tGdWF<#u{Q8>@Bd@#EyL>AwyoVj(BKe)yF+kycXxMpcZZNb(BSUw?(XjH z?(WX5wZFB`Icx9x%wLnq12wBgjT)o(*4{QI2tWX`S=Zg(nuzcK$T!=77>!_q&6-2V z9uUi=^oHkKYOnqQ5PFNB8BNC5IY%*eeLgM18_+GK9W$XUZ+w`59&qOD zE|3gl`7fZr?Pk~K0%!j)TuztosI)q%(|M8yN@QePqWIMrMVAw5O#i&QxL}}uxM}i2 zC#*306>2t=d)Rw@dB5j6_M{4?l)hOdCA2)iAsoJ>p1Q*7C&#Pgmm}?WNk+&mnnxm^o`$zU z3JE!k;DLHXsW*JNKf49sK=N4vL5JNzp-j%lOwWMH8{le>D3z#C3-gs}HBFO1xeV0I z&~O=ralQe?cT!>lD*0@%Jn@+RJxnds<{49K&x>_#%Rd|qGrV%vbeWt~&587e#ZUM{ zAyne2Z1gdex--B;eAsgF7)mR&`E4~v)KwypjsOsK%As-IyX>Fa$iS0KJo)3mW;g~L zJ^k~ZLGt4ZZeVULe{*e_zj?mwLAcvutnvM}@U<}Xv%m$tCTF=%_@SL=_G8tfacmsI zx>zg^($siE$Tt?~S+n;e_rV{Br1z^O8EcLoIa1GO`j%q_B&vy$Y-6~;U2!oO3uFHj z+EHeE4V_JhoiyG}`rx@zpKb4#cf|7QLcKoVrF(4{4Fh!!BfzO%zkIR|XaoxEciQER zfux~u8WqpRqxPqLtXOh+fxUb<*RvGXH0d7~t4)GH=zC7KaA={+SPE+nfHY!1>Ka;Y za)b`)MJAKUEv3<}gPsY2*;EMksWj;2(rbbyM0MC5)ZgQ1w^`%phG4x0yt+k;k?aoW zx&lCnOnFZ-{=2;QrM?c@^X>5+h<1n1n*2tGuWvCBk@5yGQ)3xT;pgz;nz6s-qEg1H z-gGsWosR*l}wHh<~(*du28(?yd<#48yh@&KNK3fhtU)5)PM7$E=$zGzW zXcYi<{ECKV4;GDv7fUt2L?Useffj)B^7(pO$$U`-*?b6FvI)QMDq14U{w$D(#NiY? zb|u`6zd2EZDH8JapQ9QIl!p+}fcR2Z4!;y!k6^LY-4U+<(K*GCdZJ~Hh#cIt7WDh6 zLx+$O35wD@H5}WfivHLQirOW&4 z8}RQL<0usS^NfL`(T8gSh?4`zEY{0H!2Fm4=+$NbdvKA%p2V>B3y@}?`wIcCA1B_? zdbw4aNW@DxPck8Yg7+f;sI7=fC%0}34H6(pg?_WGw!Edr)oLqYhFc4EM|+` zVO^gmV`q2qEbsiWJY840|3xT??)94~!l$S{pMk5E5wpVZId?DpG2&)cB{7F=G3c#L=J^7`yR zu0}59(aM8jUW0B&`>;W+yW_iXD7Z=yfOE_ONP*GCsueOBKF<_syPoa*fzMzMR#5vt zJV6c;5z1l2X1`HuOac{OWvnMC;m!E-1jlvrlgvNi z{7Fv1o9*OuIjxAe0^y-H%GMh?RG9e2F7n5_r;CGfkJzu{vJUL@f& zfnZOsibM7rD+bDl zd_R@Le}1?1SBvvXUYN6Hww2YV+^WaIuksytB3y&9S0Vj3%;vE@O%+eDG?$e^Z?&)+ zzIoxv`YG;K?L3$*@$TxT>W;L8s*UUoQ8fkOor@KsKSL5s(-eo5iVn*fR@6d2`b_@6a79ZvEic~Gg88kGp{bWy05w-R=nOr-bGtlCa)_l24RLeE|)L0{w+MlSV-u&KIR5pLD1HyA0V4B3-PJBi^c z0ar2tn_W3fqBr!rYB_d{4)s!y7iC`rjwX(!G63I$(ByY93LVV`Nb#I5azaPjeUjud zscf@(l4t>arWfmNW^d2VQvJ%alx(>B*n83EUf zDwg|u;oCj55KB(xuh5VrAI`bSRm)RTiOq0h+A>NQHo>O8MIHC{iPl>!LC0qTNp0CZ zzYt2;l5#}CN#8!8%E?MO4#v|4zU4E=LWCtyD2k9eLRJbNNf=vy84*Qn0OZNp4uIuF zO6M9cya2zkBY|2+EFr1T`(^F^E0xOk0I!5tyLh!cf*(XTNsG<_p?*%zl!l4J0 z#>KNr9rA{GpTB4w!RIUr`d3U8Ln-Qn8TY?X)NF2yJWL=VV4#qDiTumC8PT=5xDDSS_S%SUpQovLZs3qA2HePT2iddr;>E@6B zsSbY$205kE-~xz{2GXRNQ7tdmTvylur`6nU{P}@J2DK&&CY|1=JgKDUzsU3y2DU)9 zNHN7W*E6LZ2Jo!q1@4hB0E2@Uz)a49L*}s2?u$cNMH6LFo@+9e9IsfEB@*{$8{GTZYr=AyTn=0nJ>oW+Y&skt&J5!Q=ks+N_-C^`B2@ zd32CBA~&hmWt#)xu5k@Sm@o8|dfv!5;NS4o1o6SoYs9w^2`I$y>iGms4&h9Lk*`}= z4Ul=B*^yEX#lL`3V3RBGmj%l0UFlLJRDX+Q-N7T>4HxYaa_ zFS9{?xQZ2DkHDeQxYNyd)~jw{!x8aWnO~?Lj^1EzS^!Dq6@9pQX+f(!|6Jh5JqEHC92f}VBn?! zI>B}!_|s9vp))}XBTGptmwUJTSD=dkslG?P`#B6j(wq8OO&39z4g}f^m9f29CMM^3 zRpQ!TEpYKE1N5YOL$f!*Y9>EirRd)C;Tnxt5?J8Pu0Q)XCJsfD(x1;F-SL_T<&-f^&La)AIretuS}e4ey6EYDr|rj6yru6SPro!reJ7D|^>*{QLO~2C>R1S?|A_O(s-S))yYq1^A#N79dY@{UhBVNi-FHjc zsOsB7DON0qh+#H4G(+-?4I1ix1?T?AzCFMzp|Eaty4{wfmG2e`DKt7_h$W*3sJ+g3^4oAe@e{u`RhdP`}GpJpNbJ`4-*KODWw&T?PKn?&N9?4x5|bzLeHWU;QnH_25-Y(TWx z3QmIRTHcgNV>H6fnY9}daloc_p$Vm(P}60MM}L2dT#*?}t_a!rD2IhLMpB zS&D~rg7nSD_oHpe+S4(J)cbrD`prPBkDi@AAqWPovTsE);_RDdk8}pl8pTeXdIIf$ z3td%c#hSgnHJMDRQA5*;%X4#gOn2BsG)buPyuFi<%E?yGuPV=LQ~}r_AQ{^Jb$~#E z3~IZO)O3magL0xyAdfPlXbS@Z+C1L8LrSFzFEGGBt|~fScV!gLo9iC9;`UJHH2+z| zwMl7AI1RiQTnF(SYZX}7VTL8O_aFOWh%Xd8aNEZ#ll1N+xY*45cBdf87cWG_R=13f(+rSbCjk@P}FAuv9J z8DCH-bCEkcEfC`!frX?=Nu%;Gq0C1(AS&h^@INhe0!aHOl;4Q=x1X5_T_U#oa=PdK!9*`C3O=*Mi;j} z8d8_bI!-jBo^-&%S}g=da9Dv)fK*Oa?QXj~D`Nbt)8WJ2q7(^Ox)1ZBFM7i;B#m)I z>rWomPfE7~BQWpkFp(Fiiia{%Lo_wHJ(4w7?s3@rchsL9$1tC*mn&}XInTJ58?;U9 zXzJGFF~9sDvD#m@Z=g(Ow^Ux{=pQ!(6fg_jZ}u&0%ObxVxD5sM844jss*wc?8~sQh zX9wFn=O~S?dhTb31BD7PmHTUG6I zAxAeDQGc)3m;zV$mJO`x9&AWBCStH!1}UDr1?1;Og^&IPWMZ8XudJNTGj=#l=3}mk~#6X9>j|1(R^77p(NV(#EE$e-NJRdtn=kMDt~;TYIJH~P{9rT z(oN)xY14v;^5}SUm-C<&syzPkg3F)fTN7RxJ}0qG!$aQ=E!8 zH47NXk3{z+x(7BqvM1^N;d-Y)yOCrNn7{xwl$}*V|O}R1$b5ypcaGB_wd%nY) z>X`I=RTJn2MVQajRo-_CA_?=cgZ#wcfT8yWEmiMh(9DRgQY9-`6ai45b!?ajEp*&rK5 zB~UCl5R4AR0wX>$sh!cOn;F~iys7tFND7=6LaSv|wOyPD=?S-Wc;X*G457BUO!Az} zsF9%WHG;L&GsdB(o@Cuh&(hzOoCT>qKh-Wr>C(Yt5Y=8um^RhPZ8L8PH8QNli->B1 z!>NH~`1L6XislY|1jXN~HeOa1%B%02xBJb3D}W-XACzfLsG%lW0mNF|*eB;*fiK1C zsciN|M&cJM8W6P#hWXxc)WFU6G*i+Kv%r}F@9qg+Qr#5$<=I?~hcF`IF-}#BX zwggG=Z{t}=JZ$s$qq7j>y#{x!WZnZD8p&IbW3KY2!&&SF&Z~Q0aqEjn$mWwoncQ-w zu+-ZiV_`^e<$pIdAx9;v$Dk8qBKLG+B3Hqdj6+$&O(WY(nQSA~nT4J%X(uI~j-CY0 z8pH4z`RGhE*DiK_T7Ww->;HlKowf>Og!P&(OI6U4;ePEmM$%}?DD${uM{oH-xIh_y z8Jot$nm&kibJ;o;5xPBd0y9xD(^w}&?&bq?lA4}^!zM(}HL`WpJ6^3yIpW60broXT|kFTEVER|Ypyvb1UCpUZ=+Hy+aTasxLIYpZ^ zauDz4@eoU!anjnb)rM?n$MQ7(^O7=w{xN(iH1&Rcw08-4As2{gIjqt;+XCfrI+Pnu zWv?kYJ}8GhuSPuMu}sCts8nd}`{r}>>N}zG8<&nOYpr?gdFvb&W^h!BbQx6*yWhxQ zI@N1$1qVXCIWvSmzv~$}M7h2ESm)$OQz#^f4-Dg2{8aDLf^Dzs?2vUWPi)sU(#WXM~;z2^Wg3@33ClbyRFx8{AtaZ9gj=>iR$R-7%p=|7j~XHRe*|Al$%O07wQxsJ)G{wI|)V8o3xCi;%2zvUJ~O`uqLhr7Ik9_f^$nv7^5_)PS!#$%9r zslofqsCq;M1+;rRr%_Z=s{~6)ds;-QJYZ=9rgi^uYVf(BI#0rSzQ?gH(FTd8wYB5F znT#g#10CSq&gUK|kX<6dVaL;4Ao?rnc;0x!1PKzfTuf9O+F%@uy=Kn?)#0}=8wdqB zh4sWc9>x%v((E~utq9HI&f9EN`Gl1Mg6{x6B~koImEZpLLgB}cL$CjAF=rElM(S?r zE(UUgS40k)6Vbf_lv<^qGr{PPOZ7mX^Fc{`0O;Qv7*7<8lUdb4N(z?veG2Q+R6-U~ z2%4lk^s4SIw{n6L;}5?A4OU*nU>weP2hE3^5+|hpXt;v@{Nxy6OgMKv#o?$%Xb{K= z=uH{Tx4MJ2>VN)-GX#2D&=_1f0iJh05e;O>Y+&b=;JZH=g$932s{8VE&0KQ)EYyR{Ex9 z)s_1`MRebV1{{%p!Ee4&HTobouVPg`2(*73A{_xD(f&-e2a~kGpC5odhtO(Jqe;I9 zV!6r?@@%aoAp)0M0$8==Sz=e>V8|1Q$@qm8h*3r{TdEAFu+pMZDEt_Xr^dTQFXpiK&PT0{$UYms=LKVy#K$xM{qY3e&`)U#FN|6wlMR^*VM6Q zGeF}{>+27d=A8K*Z!E^EJ1JWanqsMPtZ=Xj9}yjlk0anT5^1iw9Nfe`TlgP0_a+9P zA2r^Ha7be0#HaXbuz#rwfU#DJl@l10lLj96H3W&MUP9zE=yk`(`wq2Q8WDf^UrL@u z!B6zfRb9>WzDaz`TQI+ranjbe*`xSv~q8$OXAj= z*&i82|NVEigg&tZ)2oR}-3LYVX=1Ol73d2M$0XAE!|D?fIp9xG|MvlODhdOZ$M6SS ztlk~Pv|!l(*`ITffL$75eNf~MypF7RuiISFLk+rFrK6`jAmQ@^w_(^PHQ~JfSS15` z6AUoio6z|*Q2`E-Bp5=Jzo(L51*lFepsp17LkcmpjPSD+G~oA>KXw=~`sX|dyu*)z zS`Ac&M2RH)-;=Y60U|8GgD=qwX2kVq@=Gy5&>$HM5WPSba2);50srsm;+qxNgnx;H zzCy1n4;dBte-{Ydq?GX!82U^?#XH;haX=f5=9a_ToU&HR7hw&=>iB{U*&Hmce z>xk^%=S;{H4D``;*0cTug-k}swAASQ?*k-&0we+AmH$)5N{0d4w4mToV5SbdXo+@IIND zcj7GQtNNb{b&cA*0}oN(wf|%6x#c7BsaXD>;SGfi`u=VWC(d@G8#T^_(nt|P`v3EF z3Gw`YeIOAi7W^MoJs3R5t99Ro8ZQYsIq^+c##>&>e_d=SQ2EdPo*;vx7|bfj-lq0j zuk2q!uJ&Az5b(G%0cTZX8G;fxto3*X=Se~{fE1hVzw@2G?t?k(zq9l?a!XDlgfaA8 zfRggJck3N*`-c1G@{G&rkQh&sD#}^^Ze7q+iv*U<4Ep{WK9`#Fs3m$LNgMOeiT|&2 zj(>?a;Bh;5Y=79Ps~xLCqkUQ*uJnVj@~FaD313pz8ST3&&x$8*zwij&@Q-+jy%GMh6y|&+L5QWpt5+rroC74jr9(n|Ra7@Krv85i z{olwt#5u&vIJe$c{kmGq0hb0z_uE6Hv-PIH%k1ylIQ~-V)e3dX&P7U1*Ae~UmMZyd z_S=(T;3L?^btlZZUnvw6fF&7@F_OJpsSri6Mh%Pc*q<4)JF58a*+jqKrxOf23Wnz_ z6`sSAypHjf!ZS$w-%FYK1Ag3UjLgTvZ*u>1iza1lD)~f#}>@WDRF$ zVU`a%j+|m*K@C-_`tOfdhRt3_gmq_&Ne;WiXAbDD-r6kn@`u*t!oSAG$KZKZv_%eof6rsQTG ziOZ}jQDM8YcMd=^<92zY+%ubY-RWjZdF^=sJE0t{5K6bY-FetRg!)Q-$a-4i!xX(H z3RGGvsyvxW1;@6{zcp48uD;){>PYY>dC`DzIgKA_l9R3~;g5^;Omnbg`rD0A#G? zDmJ_5w*9ocRIcy0A%-)zL%fs?wy`@Jpr&D=?$|tTeBt2?tEzU2ZvZat7Z0}8Ms-s1 zTuIhB#ciPH;rN-RGFA(^Wl&>0_l9Kw-B1EWU2_4#dyA|p=RU9RyzTHa%mb&Eyr=7t zn5)f(OD#+<$o%R@PCC4@Y0@QlJvsug?0Ts09+>KM(0kCSaZ)UB>A$|^%7K4?0Gog< zKf9%XNIE`YJUzk`=xDI~$x_}sZuxS4WZm1g$*?PsDXZbHf&`0k8XUBs zqI*p?ac`yfwM`Y96BT$0@*V_F{HDXei0R7_eECrB@jQ7M$|PX&epeVk0DG(=u)N21 zIzPqWobjbud3%mwEDVhR+~By? zVD7khEFI-w&V8Vq8^@bY_0aEqNA(;mqwS5YQKqN=xYax1ysPa6Udd7L?N#|C^*L#w zXu#%DVrH|VBjf98t1wlbVK*%!wl@Uahen)!+vlG?s9|+{7XzPpU2BKpi3%tUsWLZp zGT@pGGI2tv3p?@Ogl|XLZW-HTI~h?wDE0IFWHa>^jKu-C=X;;YPo! zY{gmWps~NehD*-+_Bo68V8s1vI~A+7>*!}g=aV(E$G3F6Vtaq);mi`#44-54gGM~M zeO=BEWy3?u>4O^D0OyPzG~2~~^kzTC=WU;h>WckK zod=y_I45v}9`32XP65nALOM)(m4A;&;SJXl3|L01%fy}=rll5<>_f71xl8}_LsQEE z#>33QhO|;65R)ymwsPWndUt|x&*aLvFxt$Mdh*orB2)RIZFs<`|Kh(B;fb-K?#6P< z81!Dg|BJ!AzGoT|O2%=ta_!}HpUj>SU32(3u3K_UfA1C zq%zXF?Otwm>B3=Q0v+nFzu;>DevkLVSp)DR9Aqw+*}}~+&WE2f_HW8`F`#jfyRu;=r%Ry!4Dsx z#DGF_(Yhd9H)YIxJg?e|pz}I)vADNV+&27e!!JnDp@P;a; ztWN@Bk;?f3B*IXKj;CY0MB{DxhOTh?^me7<&{R)s_RCTOuT1iWq?Y^+i50X?Ab3`d zHXhiw{4Mq|`GpK^K2a0^KMWUN&Y3+J^R`c|IjpQwX(_qGwU}WP>tU|`Z2;TaCzW2a zE~(*KRx}AG?C!16<^@-j_izAv-E2-I?&v8+oM`m{AL0u3&>g}clAh_QQGdaQ86he{ zgLQw1o;C39t!U&tEv@R5YofrGN68UFz1aEX1vaOn#M0~_`KW5l({`e0 z&3zkl>;2QbM#mi|$cC<2P6m+o>WVNt`tawx;^Fz({l#YQ66BcEZlDV#g57WOmk*cV zWI@IaK*Hr*&Rmtz<;FX;|KPU`nKlkY&~7}>9a$Yerx)u7ywVW1YmS{+Bg#qh4F`u5 z!k7_Qj2w?61iD}u^Sl<=97wG)GIn z0Ml38XKhxG-QZ0;-&YDZw3CzuyAaHsN8_Cr-HERA^@R^A^aY0TF0w|Uzsy9bNPa_) zzy?q9U?QUc290JihD?^+csMqeR2uWI?Kyx{=xGZ42g3lc(<(w;!ZawpRyK$0$d^|P zE~k*o8yGmqS+kbwDvE!TNn?|Fd2i6vw>#PfkE;ZOX4%AjeCY2elzRGIYXPhdH+Jh- z=iW2QezsU|6N{Sxa25F1-wBjCtMO096Xx*EAWmJ*O{N8|MZzhrMS5G=y+8cQ^VdF3 z0E&Bj*p3`p5CJ@Gz^0ril*kQa;9ACaE-h_Ul%TrO_O^7JswYI(HgS1 z0yJ-rp^Flc4tql$r9?)#-n!PE?M=x*th3x#?6=IJG(;58{V1+ePp=jSXgBz1o< z9DeA_q7vOFsKcbDJCfzG$d>Cg^HG^d1MJnmV5}nDQ~-?C`QI?s-?c~PSG5_~$^ehm z30s%T^LGcrxkAV=^CA4lTN0}~7b>}W{Fk_LKJl1>+~0SYip`@Ug~0%iHHfkQp|=MT zf!t-)O({!8P$HHXga2U5yX9!LXeXB0mFL$x6qg(S-~g1+gSwLU?V&se+V!s{KyEd` zbmbEHG5B#iBYO*kt5I%G&E0v0)FjtgEky$`Q&FHV?>r|ooURDQr*W~=mV$iATL@s* z<;YLhn_>{%Rw)hfoXKp6VI zD_hR0(C!8oKsxB1U(6_He~t=`^7i~FxWA)JQ1(253?9oJ(y9HXxMNaLl5kB6i6B=6 zG4)BD?in6e-azZ_N9rcj5epX}!+6edA@G-=Sbap#?RGWHT%q=3{S zni85Cp;!P%QjB2aO^0sp+QfBEJKmeD#RN4Ljn#i7zD{;6)aiu%QVgQj5o!0rMhA&MUDW_^yOmi1Bo3C2~?x;B+{Fx|xX zb@Z`4mLWW6^c>`U{+XalfalpixkLLByxRT7M5ED;0LZcv0(YYU^sIXm85zYYWfJ0# zs^h^?Jl@ZfpG}%~Zt#|XY|kLeV35xcle)fd!uV_TR`&2et(3j?hEV|z9gX!`a~$9W z5C#lxgryBaA;?O#g;$apb+T>~3l+MuH~V9X7Jr2@QfkiM@!GEUgA6Ta2|{vsEwXpA z%)@mp-}(S?p|xgHgwH^b-YlkaP$+_$j1M3)DJBJE0kc(xhdmW3zJS#Hc)ioTKqie` zlVwL0Mw|BK(7zLCxtgDkCOIW^JjRep?&ISvl)VM=ktT!tqF-4x6XMxtNT7aRhUvQERLuva447%>F;+<#c&4gUe=I-n_cDg?ti}sF$Oc zp_y4p*Wy`oC~=B)U$WT%*j|*Dq-om@8PbRgQD7HI;?hJSEno~!P|xlY9PEgslx)OiWKBitY>P)&+k=Q%Uq#F)nayqFdj`0jczr&#L~xfC-2I9E`)0)qjaO2`u?c6*GM0oer`XM z%C(kql;;2s0MzaV&NytL@yGjH1bcwn$yClS-T0W6rDUtq#VQGFz-?3{og&u&5eCwY zqL3?wM24-D6&`81P%aOQZX7VftL;8iB=vGFc7b~;sLIYdELNLT!dQL<16=%ge)y(n z%?2Cx{h!Z(y@m>V%o+$JUbCMz2R0!@~w2Mb6@nm zCG~osejCUFxhyIFpVHYS7Xcz;@X!F+pALh&>@gZoY*txyf@2J;#~TFi!^NIH%%nn3 z=kv_rQJVm4QH8j!=>nO+{s^AIY#7UiIFz@ip6+;=51v+)f|?&E>m4YF8#EhXz;4@uU>RAwm-IsUbnksQ4x9X zz@ErVJ6Pg?1eOYA101ft6-f;TOszMwH+}7DL({cx@7VX#9HNg?;0}5lI?bcKT5dz4 zhm`91*wQ;;SCM$U`M~2KCpe@IlIg0YN~v8z`PW>1ai!Pe^g~_4y2=*#Z6yCUWqlb& zqY>ek_azA|mGAl0QcMC01rtIoEENU+hP=Y&tV(z7GmoT|;;u2}EO59Kgqq6EY*@xm zb?cHT)VOHlNaOK(h=u0A5Ate|7@?htL^$Ze1EnZ{a)#pjy^MCyhy06Lf+)hpOdJf^ z(Q+K4)5)`Ov*%0lwYhIEEBe8n$_`i`RQ943_}7b%Q*B7Sufk|PLwOGZhty7pl?0jO znT3m=Jo%B7zQ5vsJ5{TsqWsRYc3kItl(U z7hDS~=iQjq%f07?u!y9RRd#Rh0{B@i0eOzJUIe<1rFYTT??E?5NZhhJ+ofvZakd@U zBzi-LQA$m9gr#LN#S0+1PR<>W=;9zAs%Q)PzN>H1{(SPH|H2v=!fdzMN!gj>xmMA7 z{TuWl-EABCL2&M!6iX`dPA`!ohN3tr9E_DrIyo2J3eSJ29&S@sFcBUxcyxF;nyS1} zAcE^!%>Arx!3eNK2z(7Dig(kXUp$M zIk6NY%dPAmmLc<^roAGv&LKgu8!Usf*J!h*RcaOx?X@^oXtdsJX?=D)cidI8(e$FL zwtADeA@cd(uMZWtAKO8Nm$skqo@j(SyuJ8I)%Y0uE;`w4dtU_?kI%@cG&P)qEY>_zU~3 zFKclz^y(z!?g=dy*-?w@=iFAI`X0)ob4?K_P^#d2_M(?9OHFOf^x^5;ly^{FZ=Y1m z8VH`%o9Ah1>xiy4?UoKaw^xd$_u&zv#Z7;Qb&`ZGE$@~v&sTk8T&_~LUC|nFeESSC!gMSH6Hbd2N{m$626K#?O3}usk)2#qHy_ojkXP2 zch&XSMyH;7Ur#rF38g=a@09su&zgxGGh929>AZ%ufrJv{}3c|PIg<(1`A?Qv`L5p~1 z!&kGpQ^)BIeovsoZwKq0Sf9 zVe8ZG6?dYPSN%Ob2ZlN}@u!Qm0xmY{)JHA#5_AA_Kwl2=gYuK8Jp@HmERJhDXI^6^ zpTw7IVot-z*bz+TdwlXgpN!T>t#kEj?vYUy8Ivn`zud(oST(BZde6n}?WfqrTb4J8 z_(K^rg<~*^K=CN#OQ(>!t~Y&*e4ByVFH^4(x!(#UkWSF-@zn|l>ISTlyf8^WH3r{v zp6opzqe>JDy3zc2`l(N{D44t_reK=^{o{O(&co2~jDT-s`j0L(2n5_y&&hMiv2j^-vIaNoLbo?mijLvmh+_=Q3VBsuZWgs8vtt57CJt8D+5W<+F| z3}+@e$oR{7i)n-O47Rhl;jz-?+z;rTy;`gbDWvM=&QBDRx2rDzT zTwRK<$wfy;&tltW;1L&^mWwCDDK==4Rk5T&)I@w#Gx?oA74%WWEzqUOHRai;e%B>E z_dPAC&JcTlt!Mb5}dWP#4S^k2F1*bU;2} zLzKB8DukTdMNP%tc*=XhaMkxE-QYz^*G{gL6gvDNTrMIWkB_xz7)h)z>Y6!t9;?c` z^pE2?&V98-7ei!tQt}fpusqj?jR)HC+~uRVF15JDmXZfGD*T`HwkkRb_28`>w`U`{ zqjkNGe%^sIKs>xFwS#H494t4C$52gG3z|^ey<9uWQqleRB?FFy`ls(oO6O|*Xg!_g zoI?AdZ-!pm=CBgWcxhYs`ZfpQn7HX6YhbtP<)X-d-$Zo4dbuv4D8rWsA+mjD58jXO za$v@^dC{=_dHcANe{t*SdU>~q;k{_Uc(Vz$%P+CN-8??uvnKudSRCPe@3hwV!OLWA z-UrMBz6Mv;yMfX4`w7ilS^@J>fsJHjO6E~Zh8GD!c)Orkxy;tx0a&HEZ~reu+w2if zZXI`P8Br9`4`RZ$A7pml_K)_;{7AlYsQ-33Nj&QRP z(w`ZP?_IgpuV;QF=;7^MkI?dV-X`fK+<%D9m?hshOgYl8IWK4n#8|jyL9?E0hlphS zQMoZmOR#F4k>QjEV(GJ@)gBBZxk*{qYBO9$J~Q>1XwMC)o2HR~u|Qw{$vP50oQW_>i(A zG?G{6DpPoE3T+3FWIHJf@BzlMkS~i)BpNPe<8P-m6UARaL%@(GI44*`tpKC{7q8a) zBWs4+qivS|v^EC9h0@>FH&z{puiOsar|LfBn z8*9hwsUpD%z1-!WO-^1rbb3s>zh9397J9l>V)9QL3!cfi@tqxxf|Yn}ZHOyJI2m|{ zx|Q-VZ}KuFR09kxu6YzQLb|k*w-{;_{DK`i)oUyDEAe&ML~NozM8QrqCpA>9!@4vw zI$oHxWx!g<<#jw&t!4i)=6<2mA;`dXqNSbZ zSaU1Qs7Q|=@Qv%=Z)k3OPGaOdFwcCOZLYU*f77?iHUpdCLht@0_4MQ}zzX&LJFS}7 z^ogURwN9MTE?HGGM}d)=l~XcW;q}6@|6KceE>@hu?vWfEHu_F%)2=ga;puiNF)fZ@ z!}q!!6n$yIPN?DB@w`u=z(-IG?IO0KpxwC>5j{8GGvn=?4SsMlo^O+WowrnB&M%Uh z>}TVgvzX8MdKEuQsTR5CbUi2~m>fEG1Z}_~sJPQ7FW4{B_sDJzP!8ni(X( zAdO;XNFSF_p(WUl(85s^7^66*bT*4Ah#Fp>%?c=@mqAG3%i2V@IMhCqE#~`+*7K`m zwxs$z9|-`i*=H*BE;6c1u}@-wVF+B8eg@yuff(%OMG(!0U-0qHkhto>r<}=XmQmM! zzoYm_3H?QXSFixjs_72j56VuW865aupnZdCxlwCk1TztDlLk0c{NnGViHFi=KQCy~ zrJA9Ok?e@@55!9|SCB-pvqlS@dEyBc0f{{z9Q~bD6shj*9G!L;IVqLmLjpJnE~?OT zLT6f2U`1S?05Sw>qwq#dsUS=si2a%ThEcx-K?xHYnze|kPNQ9xjEWwG?KR-DEe7SP zmbFT-{1v8EZ8Ox!q`(NbE0_U>yJ8rw49QiUXSj*YgLJBttXu&an!vhh!)98>;F+(_ zt#PM+9}W@ruh)ADW^nI{G}5QR4$0byn&kS412XIe@ z`C7gQOG8-y`Z0&~MK|W68C4*}oK20O?`b$YVri~w&1;Uu4UwUjY<86-ZtY5W;qS3l zJfpeMmiLkj`mfP;J6nD~ba=;d zrcH8}!c=mtS_3gev6Rv- z`R$EC``i0%-RaOO$#^9c^fsPZI>F*kgA=jGWS2aQ|r{nF~r5NN`le05yK zFbN;T|NoKomQiuETi0$Q!Civ8ySqzp3GNmw1P#!*69NhD?jGD-6Wk@ZJ2c)jE?@6g z_Ost}MvVdWhd)%^tCq|)udlrKa)abUzu@*S?+MKO`l3bOp26G}{qRjwfy0AVwW+Jl zHF@S87~1DpOEk60(;vs6Jmf-8%q5(o!rBYnpqQ7g%YM{tTVBm{1~ZqwcRJ8)+eleX z?)wI$eZLiwg$@0yljv_MHbn^J$fmek+0fmSNPNWu)``*GogHJ1KL!jC>Ad)$LY?7C zI!?Q2T{SoN7?TF}^gw8T0Hu{yB-x&mk58w`vTE!Uu_V|a`OxV?>ruO$l7~m;UT4yT zSip{&0dm5oXXe>O`#ZVcnbV|x^j9*=ym7AUiQR%#gEO!B|6G^3bCQIn=&-!VPgBRE zXiJ}a|LfY?EQQH1HVPhEA*Q;h>KsB~>_k69WEr-)8S_?Uh>B5?h?@*yEpkJfr0KY(1yHHoA24IZl~CWSM?Y;kQl0&p*lDxo~qZ$OJsUiXD47P}t2D6pH(OY#HF}s72{vUBb?I z`od2Olnwj=pM`{EH}m@Pc{YDJL3GgH*@oPz26#OCK}SlCUNw>lYF8HEbuTa9c0k4Q zds3_d*y-_1esT@pdM^)%0nqoUW$@Tj-OJ^AZr}eh_pF3hgo+=3{i57bOrZ?xC4D*b z{*+}`tN)~I4D~`{{+-9In|~o&aXA+9{?1Z(-Cub#mp7SSLBSc^`xuHiaO1q zM(7}Xo}K1Kho|#9wR-Yud@*wSE-O)-qhfMKsAyC3VE_D83V97@&Dw=5W7Y`bR5f+_ zR0H~%YPmo#k{0D<5KL0x4z&^DiUce+9UPSl6ZhC z7K%m+W4jm&FS?0>$r9|wDhLy`;p=r^6a~d06p~f7SRsjO22Ia;wcqNC{10*M3q*{p zM+h5GLZezkMHhA#{2$GH_u}P^NNrel{B&TNj@6Vu4;sSTG(B?^Q`hXTluT=4z5;TwGH{m2j@0dvJ`=A z#EX_wp*Kc&R+wSQ7JoANeY3SZ5_kOHGJ|wpk;PMWlbpWb$^dfb)@{kn;PJ87X){`X zRdMh*&o4ougp41fK!w@_R>cepqx1|h-&^g6Wr|1gVh#lqfBP?Q8K+Q_<=>7E2s&XC z5~h5dpm3ByR}O(xWMd%QRDZ+@gVTz z-Aakj|DG~)-b=GtNhDLGPge;^TGwX8O+*#{)Ej*>fQ$0Z76fWy`+E7`s73FRpgyp4 zdKjJBG2$iZylQ8#uT)35Nc39YCAoym1=&rYHNDdYy!oN$kBFa1V+bZxiS&WAfovv` zoxOM#hGdxX-3IB)mtcM7RZmCxJfimR?!z&Stm!ugzSmo!oOX8L#|ATXR8Z+jY4L^Wb3OJN8-O+GvBbvXj}>>Y>;1D5k09~iv77} zr+ST?yNV^*E0%eK8xqxo71I~EmE7|R`gj6I=Z2`Hv{0V5lzA=Vyb1T8VnogbDX+nE z>Jn@jii+TU=nw!IE(dFGPP&;a-EV<$`!Q{uZDO(OhX7?_9_d8Vh#ak?_sxuaMF3ay z7G-)6JmhnGbr}7WF0M@lhC8{lksNJlOe3aJmzsNkr&-EI$Yw1$3f$=DxMX3cjm|bD ziFc9&L;aAU*TssK&8w=vQ8@=7__C39x(ZFWzEgrBbsLb5o=Ncu@0)fF%pFk9EViP~ zG)6LxQJwNN{V+?bcviVrF#EPIoOzUw#-yR!CT$MP-Z`A?d|qkr6SA~YOKu{}CGx-R z967jnFSMfE`%cY5)IyklU%`@mSm^U{ck`0PIH9iOybbmt_=i+m5lL2xEw~6uLeE^> zaw-7`|ubm!xWY}@%G6|*6MY6iX*4valviQKyy3yo*_ORnMIAU=RZ zaxnkN*ytKt;LBAgEamSlifeP^LX*U8ISVX2gi~`)T$DoP`1P`vYit)-3(PF&Y6}n;h$jDEPI;VLr9&LitHb}641pzh( z`&J|MAHdqOd4_tj)?rpZu_X)Q6c=jDB=8;f7L1&A9C#gP$9He4cCr7lHqEp#N@hM}G#YCt%V38OP9c4ypn zRPYk@uQ1G%LMFvq;c{vfD*k%EyI{50oy;ZYK|vE@G?8OM1h*svjjtxfNKrWBzR+14 z=B1nWezG1juC<=cY21u3l-g;ZQ03n&#a|{+2ipz*&UQR4KWG$G-Jv#2S{%TaHY|+9 z+m1T1Pjsq)ct10Q>|pOf4s+S<|1=66&2YdWpnWVkxLH-C2sM1R0G@E$7`dUl#E8Pg z^3^QQ|3|#NSOA!1d}g=#^FJBQf8{mZ2woSJ=v#FOq=%x6;uIWhH?9vackgC9H3~>) z@|*T-&Jr~xgYqrnuO}AgtcMm%aNZMPe7B6Uvf1ywu>8UMWmZy`)d- z#~)HT1tHY;zOpxmJ2>y)b-pF!AM+|CU-s#t4DM;|nkFWy9Z?@*3hL}w(qLZ^s}u45 zmeY1%P^l$bV)#1;b3l_Guf`x93-`xe*zPx5LX&3)>yukE>0gNZ6VJB1T8IFAEg_r1 z)5R0CM8+%&X6=JcQ_F7DILGFtFJosS{C_|MrD$O@(afc%%ju1?- z@X8}zMK0q$Ywe^4UQu6E^UE9fRc(cuca?d;vEFI9He+ZT+^$v=MCLa`inuQIJO-YD zwVop?bWibpM)!+l8Z-m^v?S5J6n&Z@^;cI{-R);!!eFV8DtLnRuwClQ@m`)a!}OGK z<=kki&tK{?G$vAS%krfiH6E3k-*0pm0kD%SDs5Lcb0Qfu8@Zf&RM^ z!Vo~8HfF_3iWKF<`FW3H@qZpxAp+swj6Q2v$kmalUGXv>R-0kU8#)F3X5d})xBktY zk54pKZX)zRrHa@Jy7>EFAhfFFk=#vSr(N7 zOk?o&4yi^>g7Mz(DSxHXPx?S-uGPOvGu{@dth9Ymhtz8mjJNBB{OaLoLc{W!rZu** zWJWd-fsD}pVp%eovC_$I~I~{o1 zIp5^!097+Hw1J%EqmD@zDZ>PCJw4$@M8PGteY(#J=`+-SFY_l+IcT3Nr0#s*JFC5j z+&vGk1lkGyp2@s}$_U{%)pZ$TYiV5y%pa1t-oz-*9s$L@6{+>INi z)KjM22v_xfP$f=712}SyYgqh6E757}d_9o0o2~DH4+HSCvBlfwdVLi4yVGjBtIpg9 z9d8cbHg+MACbG}OQzc+gyi2hXF{uBHOc*IOTlAHt+=v0Y*Rt(&Y&@xp$QMTQdk|AP z+r#}A#Rck#_%RC~6XS!AXbK3$UwgA22e(rd?{H&Mw`xBkf2UADh5=(!meJ6nnThrJ z!CTH6V3-A4189|h4j^;ZlRjD1lPwp=@;Vw=P@CYI+%t~_%u!Gum+Hn18>OF~I`HX+ zIC*>n;woG(-sP3H+Z{|W&f`7i)7I|Sa3(-<#C(%Dq$<2yOMM8Zzj=?aH_}*<)dz6D zx3?#2FnLVyJ1$N&?iTikgh?3^(mj4eK5PN>3WW|%V;=QkRclPjXlTTLD)E6;#+2(Q zne5Snb2q~p7J9wAU1@as=cCkvaHN^Ts^Ikn#|0O!4Wtle!pdTE}>ad8V@f02au3=wrkU=Q2*s8B>UU}<Ajg1l;Xbi@ji;Cg~|=l!=RxdjMYl=vl`J_FF#og ze|WJz$K;{2r0^F@>Aw-ik+K@K_?}(1WIw}o1z;rkePNmOElFO9aVhGZ1_lhG@+Yzx z99><;Z5*0op${%=u)4GckzQVaZbquhHzYhw|Gwwz#2V|}pFOnHXi{H7lwdRvl#z(i z?&}TuGT_13d6q~^@mI({W^1J^kEgpsX+6c&njMW8s#Zx&M1DSmFG_=t-s-V}EM16R z|IZ=)--hXb{;n;BF(lNI{`_d9bxQ+hp^_G~MFqGGfjJ^u-B&nvk+uDrg5Ouvrhk1` zJa%6ft1y^@rs%k=S>i7OXP&1}h%n-M$|cW&dnAW`qWHvrMoTTKsRZ~Wtc?8`0RgM{ zT^kNI>;W~kDUDwge17+Io;3d1O7QSs>*WaeXb)uz5vdi%PvZ)T$olaQ1NApGkXd#< z3^U$Hx>mG<<+9LMT4Y&G7&PHT#4R4&07e1!SLN3%KKT{kG4r9BYZxuVLSj+U_xYJX z!>G2iK%d8nt^fPc`RA1U_Z{pkK&9GX(tyQgv8LxLV~+r@Uc46VLpwy!v;b>chy$}5 z#X_x%gGYv&r{NEb+#YntBV`SiP@cwYR}KgxxsX_*n$dUosc!xQ7V*}P9a%wPgrE7F zU6chy_mMKwM1mgQm^`qbd8vcYvKsKVy}G`+jL06fpZWokIm_wOP9!-qC~Z0YWQ46> zZb^j-ywLz-+}gWAZG5p`FCzk?(UtNR)sP44C~fO1pTP3z32mx47AM*6C-~Y4*#GCX z{ipi)KX-3@k~!uRd(a76Tx_X-loCL6r)NGzwO9w#yn_^(fOG#54Fw~4sYrOX@q>LW zwMgVL?L~(mQqOMxOnfYsclo?T%uy4 z6L)s3f)c_s;oU$xJKSIKg0X)D@#Qn7j(uL(l0XPc+R|^^6PBSB>(OpLWlDMv>^Jv_ zRvME|UT*p%!MudsMURBsI}39;`-A_k*g3h)OeINe{ZZkwLudxma1zw0?!T^&4&a}Z z7W?-K-_*tL%Zixp<;4CoasTIj9+2+(jQZWp)bMzgxFJEz7Vidg9)KJYS5KU25(sS; zCL|_mUE%zAH`^tT35aH+967Phv|3oBpo5KMU|=vSz!s(F7dFV zeSBQLnL=(Q4(sjfe9da|9x20Vgojwqg>n^>D&zXw#AtcJJR>TbFX~6l$AvS!JxXv^ zBJT-OpDPBf5dP_p{!C!R_O2VPeS%EofJ3s=d5ApkTNFH>kn9;@cmAgyBCvX=8%R9oCTTRf9R6!r;JsgnPip z5OeeY$O!-Q&A=!@xZkh<42PC)uap}Z86oP)_v^t>9fTEn9r3(OGFs*(sKxZdkJ zER|scKEVHgiR40Jm1iJ~*w4e}x1Z5HnXiC*zaySz6^e_@$(4nsNIJ#ADoJ~S^#cJn z{`M(f${L5<7@;S|TT?eH9KiJ=Kus|UB?SRVF0qTogcyd@ajrVv>U0dT7U?4%>u7B&UXadLoOCRf z_^&^3-s7TgG2;Y~UlF_y2JlPO+YTZ0KrB(7(TN!+BnvN>4bsAUr7aRN(_aPL``r>n zw{K6mR8*!`xXRxU(5G+u0I_f1(|IlbGxz!L`+HmH3qvso|3ty1w}lrXMi}u7we167 z1XJ(CVi(4q&FWB_zb8XDpbUP$K=Q4Oee@T})7Q&~G^E6%q@;Enn2)hT*_>7E!-%a& zSc!eeSWa^N*je=a%)~=mZ`gtRZ9_o7$Y$C0ots25K)o=unqB2UOYu5%C*zd|%y}z;GlaQg)!;mbL zyuu<%v*0-1R(8g5ojh}{-qEZskz#YRl5c%hBG^#_MfYCIU7ooP>QnLA=6vTYtSO&u zfACh3pdVN=E=5|;wwfmGov+ey0eAO{5@QmN&HOPX;;Lwwsqw_=VR8$mG&o!2S}sNK zx+$V^Su4Kr# zUj~0+Ab{ihDcR9Uc;S~8br(3yn7rSGC3Jh%(0)%+;!>$bzWG4)6UaIGj>?(bkJV|T zp6LC&H_lIH>VBDR0d8ZI8AB#wM$8Z*4;O60l!1t#Y*y4%r?~Yn>w2*k!dHs1BHB_( z%|vRvIqrT9*5ZMIogld3wOPM3cLHUlZ2ftnd`WrsY3P0$D%j#Ddgv9U?4d{AofIdc zCqNS24Sp;I!t$aOL+4qsP_;kVaq+cy?{J+ZJw07#));Z!1F1}BNTn%}VOq!oq$VkG zcEgePa~;LexXbtiYzQY&KzB1kv=>KT*VDfL_!L0C*hJzN<5AIs0sWUUc~mz^i?lZ< zh67h7Y#ldG-vszRA{=(p|AGX%ollhg|EmfNMD$DlZx?hJTLN+z%INUqXFfx>WccIq zoFuDF&ZUy|3D2xdtU*M49rnLhy?lKZiaj+DVuh8Cf9(+iYX z57#>q)%(ad@4Zj%D>5n?nn5qtdU`CAkft`*CEMxcrb`2HHRPkt{a`NXVRhRM~y@23t+{junk*$d7uzOva~g z2AUNwo7mZ1Z^7M-{TTA}+)sb~t5+@RJ2$sKztLqi*s?k5Uw9Fps3vH-?|&K}j_t>= znLOpsKtm(bq|Eb`dt=Rdu0-|M2IfYt;r_?>z1q#Td?coqpc>bws+RUgfq*r^`{Qw7 za^VBlma!3`fUw1N>;o%8D;q}Ix$R_170JR1v$FuMl+fe| zZmjqTv$V@y0xHMHW6|m`@3hpRu4ui(DyOF%@a}%QeRYbuL^{1~j>oT?;PXa(plY^e z;JMALF~up(;yhQj$cRbHW{ISW31Umu5CcV^%HJtJW040WR%>Y1or-{w9E+bL55=S0 z{Ka=0haRmhEW03z-urQL{Eas#G=MfY@U_ihskYB&_>=^lw3%4pw_6!x`KYJJjNV<`v_= z>8H7J3!?XqhM@+;H&$g90R&oc?tP*FH1%jV%LSc&_a?HOM76YqWK^00Z9iz~`dJsS z*?s$5-TUP&8@@w|wL*nm-Cw+Zc)nM_`8fPX@ZR|wkr&ci?|D>^BJln5hm>A-5iTl} z0E)TZpR5U*H$q9qs-lA7ut?GHI|_rAoTJ}DsoLD0nq~M!{L*@!CPj+;!!cYZrtRq% z8060hfh^>EixuK{@86GJS_lju<_0joAfm;IT(lvgP0_a9<#bqckv}r+Pt$?gKryS< zbA!>`H!Gkb$5oH(t8j`@5>r1S953qDCbuCB@6(Yw984__Pdn)NkCv8x6#CGN_ouU# z8+3fv+dcJ;Plswj)yZemtEw$))FvIk)Rs*jt!SA{TUWB$*a7omLpof$HlZgeB@ma{ zQ(YeqooF?NaU6B8h8lvPz4s3L81XjoRPK#Sqwq=Lf)vjVChN^P=V7(STD zv|e99sZGSJAOGE1MC40SvyQ>p{@lMAm>ND5g?-|^Hz80_pq$F8(Z10kVB*wu=5g$| z7talW>h)TO5j9YP<*Q?nvO)`;dQ7TYua#tfy{XA<0!xJ_0|hY)IrS)1vY8ov3(zh8 z^-0^0_22obM-J5hY*oWla|Ig2Vv60wWkE&3=Q=u1PsgRX2~gYBQ79;*V&ciuhLAI_ zNAzyZEzp(#Lva`NG#joL9|~>q zb|@6eq3`b{`aba;Nsc}IjTm{3o$u`n(7kq#eUxV&bzgBtzwDCjfqODp!V_E~4&oUE zgrUhR(Mk?}hmZie7~>lG#^{8{^Mg8Eg1juOc&XJHm*RV0XsM`RqK07a&GDER5vIfQ zC}#ll$}pTx`W=Rv=FRJnBbs8n(8pQsrea>F9Okbh5Zam|0NJ04Eu;08QqygkIq1#~W_*NnpdZd!YWq<@`@!DH2VcX%xlhyrj z{Pm?J{?^n5D%KXFH1FFQcrrEB)L-?y739>mH#=r^IyY5ji5gflTo|yG3w6>T5z#6c zjjO0hL3ZwDf@~#1?MW{n7KK!*!7H7vm9!9Bm4eDmzDMo{q%vM z9%4{>tzNM;yE*Z%{{%lvK)o+eD_ZD6ei_Rui}E6rz&G64#I$SXr#LA39AWG?QvA!1 z+dNkI@v}E18W}~KRN&RS?JPzlxf)ql^GGX6@|FabVPrqFTAOm;I@PTD8V}B^bo|kx zg!K`O>Fsx_zu2B}b8E#%pWOEheO;yCUSVS^Lnt(dV`lp?ab{*TxSfm#fGSX}S%=3N zb7^isd!y6(=*zSP)E+;w3nzZ9$!)2uB2P3&mcb6!$`uoknelkun9K#qJO(fn2 zokSUp`op2qdRWA4dcEh+d%KN1CjHRL^tWji+SjlLAhy0QO3=8B>`5c5;%Xuldcr4j zh%sz~y=+x=Cbh3evEuLV(X<354MrZPvVe5=k>Sa3A@^YT&~iev(wKR>1lq43G!l?( zjKo7`zcGX>W$y`_vr<<5Zk`}1@DD0tRL8gIM*&#V>OARHwXl_=PB zFHJ|7IVUJY3k7YDi(9D56(cR)aJX9pa?@QvV-LEitQ=QL+41l`>|zoRmQN8P!?c?c zg(Ue&SbjJ5eSy^b-Z>2nlW<+r4_xl*sc{2FvMg)K*;wV@uS2mv`=#M` zZ~O0<`xH}bU;L*8wTi(M+;r-)zE+CFT*Q|}!7_Hg2HjM0ZO{EJBz}QoL8fwn_*L#f zrvXclRkig_a*-|JJ@bg{+Y0+dCo+%g<&oWT36HVm5nBe#D)|22*y9*?;(=lIG;yvD zrTmM@>jA4X;n||EmAlw9a(TYb6Y_c(rw75t5ILsE4hhTNVd zcD?)hcOFD6YX9`6PwT~FbUA%2!&vY1CfI***P}e+Gx8uzFS0TN%*k9Y(=S9F_ z@9=hU@Tfh{IX#S$l`Jj$3iX6hBS4=3ZO|h4Pk8cJ$c1=Hm$__G%5Mgm6Ly${A-0Qm z(ImVYW>(;z=5pvCoi_>~NbI2iu42y87*_?S1#-m*Wq30#m6XaA#>j=zkq}_V-d%Kh zy}NIY!)7K!n728bjjbZ9N(bn7pjFqszTNjN?h)+$S%A%Aw3d-#D7`8jdDO`8k*w@O zHHV^Vrn19}usI(!Rt%5!tA0Fv;A@lAhlP#Q`0&r`bvVSxTi9ELaP(^y`SIpb-*>E= zjd3(HM$ua??k))O+8qd#Ly-BTDA?d-nI3IM&oXftlsMA(vrz*>eml34+caOCoa!_) zG2v4tsYOX^yL#paDstZs&q9G>79EFmuQu86z1{cEJjtR@&<*6>^q#~xG3V<1*KYXl=+{y^0HB~2vapu6FHHCFb)?1 z&sW#b+`FG(%`D(m@%Wq1!%NG%QHIG*AvbOEgDSQky^)Rh=F8gU_OPRRCPKpy&qK#T z?L>GYrklUs6LD0tB=$C!_^D&-o@@D%%D~!d3fN?5&|9Ew+N{$$gqu|fnF(5u6zY-u zD`~Su^WBDf!*kPr7D^ARvH1&ylzh#A`I63Sm*5Us(D`I(xi_!%G+bf2rgZeYHmlkn zEfCY|;~Z0V3)xFhW>10oc$%fUi%7f*nsKe^3#|HYhY{#^{`5n+BY3&wqQhVp7xoa9 z?heZ}>CjwXf*MxRrJ9LJLeYZD0g8=xRB3Kv(dQ z53JvA{EVK;4^=a{puPO^ST=!mUd`z2#V-}2D7l@dm`~Dk7UzLzsnm-mp4o;3q%1?@ zby|dsG2RE;InaeAm8PlJ&*e@gTxO~_Wver*P)(6)m00~+Su9k>rV3TPVEXH4%bz*} zL1`SJ>SBDl=>~3OU{S*kkE#lCXAx47MYMnIZ)vhBoF}4>c( z+TvygV1p^U98}$|OJiFjypu#n`#P;aO8fkFkUkJx_GG*MCHpETE@Ne_iQfxDtB2I6 z6uQm0Y&%qL%V}>=5g=*;uJmrJIlO9^V$QLHwEsHqv@ozYI(O^a#S__O81ovA>+=dP zuUVK`s#8Z9ntYfq0Pfh69e0DX%A8Cq7p*()2yVB}J1#U$uA3Izb0G_cF?O`7mm7oN zu)_%mCAa=XUwhVut|$6px2;(A;(`yvxaa9yWE7$d`w2q@sz0h?s>V0FLs}=eJEJ9; zqFey(II0>R%53_2P0j68%ani~4tT`F7BMHs{v}B~|0}BAxO5ykS3NFd*=sb$=4T|#V!+kP2EVf7|yfspe7nMe30_Mq&$`QKVb zISeXq-Q3O6K+D^v^D|@13SOrGQf>74o0q*jkMkZAZ@XHArsuyhqJD3E^ah4aPT{HP z)c5Xa2}_*erz`au+;`SW{IDH+2Ly~-$7pHF7c9(4A8T*>0YN~~`Yzca`LSP)>Gcll z9>J?0WK$4^zuzrg)?yiSTGcsBjiyk%8nS@qP-&Ul;^F)Lw8L}S*nREByBL$iJ9WJ$ z3Bl~&`;OF(UW;LFgG|10>#)iUTC7Yw%Uo2tt!C3~^v&FJ+GGt&Z_@y7SnlEzM=$9rWV+cvNLmiiM{qh2{^qNMqPcESjFqbG*Q_BJK)Ldy{q zsOFRFSFiH0CG+smvI*71*!H7|G3D{wWrz`p!;biL5OM5TvFkFtJYvS3wK7#1HfeT0 zUZYZpBs%CQg^r$)b5gH|nd|e6O~eh^%@&3UB#I%W?AA201t>{VTYen>VZNwOM=LH2 zBl-Q0f-&rPfkeQ_CUeEaChB9Uvod-&IU?X&AYKON903iORK-rx6A>#zdn!ZZBB;mW zH;X>gUEk&|l#9MK23CCVIKZrTxiWJFm!Sk{-qn0fq~CeH98yzQb(_ z3hPd2t$^YXB#h&AGkC)HH@ZM?)G5ln&5IJ39|SeZDV$OX?_9($h;H|p{a)b(di0HT z!1)lApog|g_2)mh>IEBaFr8U7k4K|>mP3n6fiM-1S#FaW6c}ZRiR$O*5(!f~VQ1HH z&wD=hI|Bq}+8jFWEmwqVp3|`xRV^C@rb|Gg+idfdCYKJVSZP;a-8~W3bGg~3{)*Ut z**cZkye07Ml`}T*eJ_-AUv*6rUOhIK0w4nID39*OO2Fi`W_Ci$IPTBWlmu~BrI_#^ zJ`XDA+Sq0Tn~5E7st~*(B_^Ot=n5xOD&BO|3S$#KDuw>GuGD9r0 z?pxdZ9rG=h)vQ~E+~>$*TOZlR?Q|Yy5%#bEU%cQd_Q^KK;A&=Kvi;W&@^xT`&BT`` zdM|%+u}9M^8O&czuJ6TRPkdV!Exl2fA%Q$#w+^ z%Y%PiE~JV0PXm<%AfQFPo~k!!X&?uk+gi^S{aCO9VqC9@KkR8~Ki*&M;^&Qk&lT_U z4v4$q#4C|IKhNwoh|63q<%k{Ex{oX@V=SGt6{wG9Zi{#eo+qNdXN@~w?`R9DohbcKEnYS$1ww(a??c^_tf5xb(xJFf4}tg z2W(BvA4HiK*a?W&1fO07#DB%EwC`=JOQ{9QhDgl4954NlJIBPtbRy~y9Q!sumY6K{ zC>PbLk&Qb(1@YdErqR*pfFRbt_cIwf9WNx$1Xcj$050=otMhz0yW0>;sP*)h4Z{|v zsvB7s^~iz+Bd-3N5jPpD6|p3dJ-5?Y!{p6u5R$22xObHAE^v$i3Ty(AX{ zr#%cpXN`0IroSIv3#aVO?mWa6;YGa9c7PXz`UuNy@?IyY*|ty<9lt8Y5c|$ls7s4Z z_F`wv<%Szoi0qfTlaHmx4+jsbGk2OBLtbqDgzOoN9*=q4hObm{gSN#ZiJQ82_8WIv znkQSrz-X73kB#CL9S8`Il|@5C0hgwePq-IaSppm2MlTY9If&RV^<1sL4a-9mfklz! zXNVMjd9%JZNBI#nf1{UI+gp62t7cmtzJBEUICqyAy3+Rcc|Y0VhSF4&Q{r{(t4XYz z+|Zy91(CqUL;u*6V5z8oA-K)nS5V$`VeVnS(mA`L*~z1NqkX2$Yl8VueJrJ{hk}63 z1*7{B$99vaU4(>B`D;J(%CeBqn$Yoqa+ZWBMB?xe)A-=|XcC-lOR6!nOg+0Tn(tiz z&QdF%Y*^)U$=S@00sjbI&rYlc?LWI}OB|jKeHZSb5Se{AFH_Dg^l5Ry5M!S4R$Ns$ z6q=C9vK2u8YP!ZgD^zJOb9mO3A$TF!7Vox%9Gyi#mVDY-X^VOo$0ICK(^J&K!`;7q zy3n2DHeTC386KW{?)S+`;&90bQ82e}Q>!KG%x|w*IeV3#-4o##jk;l1;LT^-*zgvkiFj*uttl(^ge z_T(5xcit>;r|mH7%qox(!1uQQChM=C5-t z>T>b+ZN50fGnzx<#aL!Afq4u`x)^t?VDr|0{_>;~JZuP+`oh&FfnGppAJ#jiN-&-z?c1sy#G;oUWyp+(@|YDI}L1o~0N$0rxE^1SPWEg2bzD zF1j-{tW`+ihWhT^=PF(@di|VFy(J$Sm-_~FAVbzxGX^f~4*eWo9W^0E=o66gAy?o*w{?ot{wke^>8R+)n@h|(ANAxe}=RI!$ z$t94H?qk=S>fm5;mgB3dfFjVL?k1{kjcMVmSajyY+Jy!*DM)Frwz54eb2WbJ{C@P5 zo6Xf=st($i5<~Ws=mY$T_m~bM_`I*PlkaPY@Ze)Nw1p0L;!-oW>RcNCTMbU|P)%0S zc1aD9@_C1t0(0}{*Q4-|! zVSX_Js&vg{GyA#$@5edrH6PboHiv6LH5J|Fd_pl2+wg5yC7IS6YeL-HkVXH45`+5( z0%OJ~S5={hB$Vb5c3E9AgWx;LF9PYoQF(r=Yy!%tesu*G@=7dzP4ug-&(NaGEY7)? z^is~Rs)5G(e)R6NA$NEwlOs7MT0EteaGHe4+q~_3BH^YgUo)W#mH+KYyAj;lKBDQo zy*%r=q-;*gS~d)|44>5H1##X7a&55u*A;@j^E#YmDddx$Pe5Bf)-E!SWx$O z59#?au5x_Avc{7y;3z+Vqk72nYi9Wnkjf+-XAA}UWvwcJ^1bQw1$iF~ia z_jh&(vM||>q8;0g4$^IqIO*@Xm@QTgD>)EOTZ2{~l?HvEZn|R(Ql#nQbdJj2%#jzl z!usqOMLbSFo~}~0hF=9h2w?d&hLswB+FTtsIf@-ji5HT9jh*I7swxP3MQRBaM<0;N z0FRk9fj(<1fC~l_*kC5xdw=O~InfqX|d{cqS>#s&NsI9edRgP zpUI5*J4I6gK{d8cdT_=cTg><*Omt!E(F zU26mJMsIwo)Gn58p*X*zX_w2_&s|;{I@1k%903Us-9l$u4UeiRtosS0fSlJT1N_Ek z%R^%Ok${h$ZkMOYIot^=GHAhoc5Bj~%bl zGDMw=`5Cr;$Qwh95)-*BBm$MG-L;>D;HHRu?@GqTFS>Px?x#xU#5LcGADVd8Ww~go z8#KHBs5I_g1FoGu@A6@J^`_GZlTv{eoWBMB!Akpe8ox7bj2sz}l2j`j6nvGSqfhT0 z)>bdzPtYqC{!K9C9b@rcKWzsm119~-cD;N(Evv9J|Jf2fTPD2%PRL;6#k2&LKca{1(0xNZ!)CJ81=SFp2gS;`sTT4 z2@fuVhqo%KHDN4F{U7L#=i3^v;%?||cjzt??I@H3;hU%;KI0>><=5c&O}Id=igLts z;4hYz^pleb9!hhF7Cd?VCZNj;dgL3a|^f8)ZOM&9)o6p^XxFb6`x(yORub9PZeI~7Ccm~IT>r; zVGM7=+?k-`Oq(V9u0Mu`-?o&hTCB4aY#jCckd~bL#nDxBm<()k?4T%{0RoR*Nn!a^ z<|HtePQFg-l`sUR8#Oz4B$roXqG=;|gq>7W#HEt02ZAiRji;l|GM&4_n3e&Rsh&F8 zHY}}U&qJhcl}%|Yhf7W3n1T`T9U$h9Vm@CIDVg=Fs)19Y^Z5){O)(HsK1V|!8HNAN zOwO-g$-E&`@W(=kp6kGt=}pG{f=G7VReYoXxvvr}H_q+8*h1?2S9cVX_-2F0L*tn) z>pYkfH*3Y#WABv?b-^FeOF*4D06c%w6HzWM{3(@3(!z3yfjQuh;~V8)kR8x23fKon z1hRN*IQ{Y$T=G;Ls3#B`AO9o)dsJ^>Vd10aST&{dNZRB!QK9)Gm7;r+M2z3eEgyVz zbz*bl9MjY37xXB(`$`o)WsP=w_!?i<4lG)|Z2Ai}UVjaXrJ*{!wOTCwK55v?V_dX(@*+HWrMO`kU;RB&U8 zWM8p=!uRjlF@z%6)`uJ?YVN!@sT33!MHzCu68vsv!oR3I56fKHj<>6IHMvEr7In0T zvK|BUZ2cu<60SsDP}cD0%Xc5z^61wYCG$4P0q>wxWk!lFi2)t_O}3`fKk^?CUWLjR zM-os8gtW}NH;e5Bj(-Ak&?-wW3=VtIjogy6*%QsfN+X#+sfW`D!ze3#yr>I&ekFlD zIgh(Vx;%;X@^!u~#Ynu%`<(ax*m~=rxY}=7cyI`AAvgqgcTI2$1ot7h4Fq=>AV6?; zcL?t83GU9|!QI{NyubI{`<;8fTdN8Rs`z8GXRqB)uU_54)RWc+`W_seX}H&N>$RGy zaRJ!XG>X%_6^U{Ct*T^tGV+eK{t*@da@vfa?x~EhLqaO0+U{hR=Rsx@oy-UrV=q>T z&UUM^#+%2&A3y5n;g}RSID3_4K&h$q zJP|~JDM@Vv{Gwe(TL zh&W<{AD}k*#ledG89|VS`NIi9;xM_`c-(dQX91gTifZ9_SPhLtN6cP(^f%!lQg^Jq z?6&8Yf`H6!=PX67O;pMrs!s8^hwkJGJ^Qv*4Q9elj&ieWm`A{@FCg{yAQ^ZdVLgJB zWwD{w7iGB{)igL~Dt&1`k+q70>1vy@6P{I>y|Ftg;G}_t@M3(10Xb-2aqT+AmtzV` zYK_#}3P@v~>1!M89z5zPa*_vRn9te=%_Up6xjP8={MXP>DBwpta7DDPr4E(cm9|_01RV&9E)Z6T@Fqo+DY|Y_2Av(N(qA^y zK!y)_{a53|wzV@^nU6MJEc?7C5Z{IMgfIE|GSK z_M=3TZz1rE5y|FW;;6LsNdZ0mLMJ2VMDd<{@8uH8ps99KeWGqw=ihjq$G;Hmznb<$ zC+DyJOH?^w^GJ<$y=HeumiyEPa+#peUFe*r&R%Bt4wt()e;AVNhy17=%V%~hRq{T!WJFVIBV4395j54&Y-g*Hv6-5R%k+*Ogh z+A@sYpTmk5F%AK*sX|XkRzJPWR2^&O4-t*OEW4h}`G!Q{pRFNO1O9TIcxpe^!!0R{ zKKpkVc-;#r)!;*L>hS;QB2wA?3Kwn?VbtmV%b(nwq1;G=ZfN z67@B%?1}vEj|qUi`-@r7-Cja;Vci+w69QRgvS(r@X`DT=kDStEYZt8v_;@3o1_zuT zGZX@#NhQbG8iRtl$tBJ$wNcP5S!$|~{(J%{Rp@boh1&U@eY|M~Q|G({sW*zv zWOeS5^bzR!nX|-SM%knJHsS-i3Y_MyTn^C`Ci3~xUoHFy!+R)VVNk2c0&Hh_*|mGd z)*)JcuR4eKKF{%Z4?K16JdZwlA!QF)RiarP(e=BMKuk41hFcZ4^si#=u(3I^I)2WP z1aFZ_)>w!&RIeZB)3JZuo6;LIHHtxl9^{57o)KAbxtBlh-8ImlMj4R?!diao>XfYJ z0Dj)Za{28&FS@DozyUXR#@0kXiinbeB&|f4K z^Zv_NRKXMX`22A7%V1#cCU!t%OaZh4bY5=tJ<^>SXa^DccpgLD2L>*aSoAijejAl< z4rsW7J|0&bYiOZ6v<2UNH*0W0cQw1M?yFgBkdlr}*M^fPeiP$g`CHYvu;3cJiF4(h3DPI|EWbcB`0@a z#hbz?ZXfmO=0PJ)=KC6NZMI&1Yf8|@uM;6pLA|SKtS+VAfutY53;~YVnK-fWApJPR zx~xx}SfJ_x?cvY8G(uvS+J@NAEULDQxGn0p z@3-aEKj(!dk&=+Hg=*2; z9v6MB{!lq^B z;^(Z`mfewpIME2v2h%i8dlJEG-5Z`aC^M|e#%)S>Pj$Ec1`cZ+BluXmk8B=M>Fi_F zL}qW|cRe*=`j2>kkSK5T>j6PDwo@tH3D<-c@$)<7x8w-Np=5+dHma))sDp`rTj-X7 zXq5L{ZuCtcW_EFwr*22elwuNEXDBY+qNjlLcS4@ve}ox{Zv3C|vN?PaktvHilnl=tfJ~c#458fw zclnnc>v7Z@bO55d=U8PZGUt-EM@fa|<&>{tuwD-BDX3=^JRM#8yig|FXLOktp0$$9 z`~1_+NJ8xHkhvd*=rO|_%u{099Yp+IqoKhLPf7EUVlPu@TE3QlJO1c~ta5ni-nmxR zz>}$|rI$HDwWUew9MN|cXr~^l{sXud99gFET?)H@^D7c>_AAI2^u-iY)h04|6hu>C zwfH5foXpJ7Vu__A5|jY#e)q3JJGht5&4gDn*CNn$D%!K2IY5OPJx|`X3x8hz=)G}j z_fq~bE4`L4w#moO)nm!@_W{fSEzXiE5kG>dKUOPQ<+|BXR%+T9mq$Bslx zLIX!L^;kx{Ii+UI*Of^oX-zukQw31l*wdDD2vKthNw^-fBy9ht@b=LOcSL4~qm{Az zlC>R(pNeel;BH%gEWgeqTi7A@JCBx%(lg{(e>zBQ4CYO)S(5xQjp6U>&m<&| z)pHUW6kVRO`o92GNoJfTM4;fYJP-hX1>q6$MHF?hGMA*gKx_P*6AWI5Hi#`wj=P(S zuhEGoQw2WousT-Ya9S1amk!)df4qmFvWxQ0>n;GW6MmMSyQVOUyW6%H^V#y6LtkbE zZM{LC3NI?eCMfEWp|$HUB*YIHvHHa0YBx0T#Ar;Oiwn{IV7)6_-uxBHY?g+N+qXMV z!>%%em*x%Rv(GyCq!1KK>UuK^8R@fC&nd1dYg4nL1eyD(JfExBs zHlKZyS&@MOPG!-DAB-}Sf#B1ePg*7GYp)L3O*!(wnN~ad9p^{r)aqBa3?j^izOGc& zSJ+Yb4fl-44TV-aJ{ai@RZ-BEvvB=BkBw)q}Dh}&*6RJ$E1>u z5GOx%!Y?#Lrtf%tv>9Gt!8{j~WS2u#sx6&az~=w{?%Ari@I>{o47!O{j?j2=AZTtu z=F(NRtoyR)F=Dfqwr8k0;s3?$tzxF7fvk>;z_nl5&qLoUelsv#(Z+Xik0*$nz&*2u zsuk#I!?I;%Xub8?`%i-b?vah0>q94^P573+YaaOIIVt^4P+23zm0r+YgxZ$_syg!* zu#Nklma^!D>$P#u;}t4ruN8;~Voe>b06F}(hp*xDBzS9Hw2CSFt!t4V9P%$QhrI*{ zr`waS$P~L$b@x2W7_0xmE=kAMP0)~y%Sj2$O`QYZ1ukTmH7fw@P0PMjN6SLrI**U$ zUuY(QspoELShT}4@*kT5vZ}S#rW1xttLO7S4(LSOCGyxpCgw2z8JT5(t^~jnTv?Br zguWuzcBsv z)od2ZgNajIao&-4vFJARhFnz)KSPS-^_Q-p6Q-2RE(5s41vCpR6cB}KNp8JecitAl+T)^EO8+}|s>UcIs3)ORRsaD5B{F8|%J)xlCg^*a zc0@=X{m^*sl2hrmrJ9zfi!~!lI^U=7De#g^tkCU9VLP2JT}|6{I)h2U#g=aQ^}i1`c*oPi*v5f!8Ef!LF`vi zaZ!%NPFDoc7`LkQ8ffHScXSau*0@^Eh?0u#VgzZa-VJjP;8;(6$kSmwy^8{e=lFz?Si^EU9 zjT2G1BHpTr(>v1Dv;qTMkf96A5ttA-Yq@$}FFv1+Pd18XJcV)O z_shQ2tG3xF?YQxvxVv>3g}&XrmQCWj5-}0kZ$HCD-+NPUjP7W4&iH?Jk7VH4I4pZR z&HFUJ^>J@tScb}}Rs0Y6QQ=3wfsVs~h5Pjazx!2tybSlUj+e|c9M&28_4`Aul~Kb||HLl_#nvEcuj~ zy*J<-4Lh8k2O65_z(k64y!;ap7a&mRkknw^@eSDJ+QHBnKJ<5K*HWSf&2#DS#&uzO zEX-46&ALh9>U*DVnfiHZ#n|IkYhmBKESG9-LxtU}Ea`B04!@ZQ=fPTir_S#3n5HJ* zT9n@$@rj1LM_GgQ#oB!$n_fltkh)c2VPU6`@jd%8tIYQ5JNmv53N8c&E3qWarFSst z#T}P7jn-EfFW6#3v=hYD#hRd+x*Nn)bhe^F|9@-M2_Vk&5NLfzb1OpNXYrK2@2C9r z!`=LQKUuK_u_7z-Phhd1**1`w#cU;FQ{dw&aZC-{O+4q*VKyH_w%;g0<<-^Q9Z9#n z-$0-oODD_)gLJ{1g~C&9)-Al^=}0Stm|OzSX!Q8K7;!2F^T|6_5t~90Jn@|6WQVid zM^Ov7`tq;m-I(4RaCX+-!wliK+63LPT|-$cq{!#8>0W7l&#$+IioOqfp5wl^wnynu zscJ<>p~!>ZTJml7i0vqB1HK_HCG$Mk zUe$>CLoTfpp??=#z$|kM`}~eP;#&Vz6IOAUFIM!!^>JyW59w9b{L9+yVjzqlt#g%O z!!(t|*49N3d&B&MifjvRJz40g!?;I}BXurr&NE9gFjgPZ;>dM5LTvwC&4#nZ&tD%d zRyA!;E8|X{ukLltt?R^tO8UqCqHj$!aP*U62sx7bQUUk(Zc*}h%FJKik0K+>;6>-@ zjYcqFLLH@l99wb?fVI39}Oe5HzWYFE`FQURwQ)!vb6LnpR(QV=Go?t&iYw8V6U>a;WO|*lLqI z?4lww`4)clD^@3t;9);4_|@0WW0YkGLt2qM@g3SEz=f9d47iZBv)xT$E;u-ZBv#g@ zis{COsT(nZpRL`{#8!*KL@K;bE!w`>YguSqWK>8W|5L7g{?39s=KM&g;1wkT4kpJ9 zumD)|_+o&3@8%-X?H|wX?|Kt5Qv3gyYGb~mKlh$&gXZRF_8fLT&Q-NGQts{`seM=m z>KZ=``V*wV-Jf?D(uUqYk0>`Uxu-wl3^?x#oEtwy@?VpLinM>=pJX@t1tZ>x)lKzG zSPHH^^S%>9ueE;u5h`SWxwg1dAg%fCjY5ipTM{N8;B|$&)%r}T<)QM4`|0k^9)6fA z`M2}E1BFFT9Aga8b+;4ESux+UI(UN@A?cv5{Q110@g#=uM$vzja2w z@X~!D)32d%oUDvcbox~9vYAxGJ}fa2Z%HD$vYr>gD^s)&vr z!`J}&GCoOB(eL451x3N|+q5pP$T@|erq8riN}lO!jO)=h$*gr!1DQskiZR#qKDXsT z2dWB(KH4L!pDn)}I(SBYd))-P+8ih>gI{%GjRqsQsizEMm*&#fAC%{t%`13VeR_S1 zWdk{+FeIVXuFn6R!hGRedQIpVGbs68_FsOWFiH3J9+c2?0h)MmR_#P@c%5}<6)nuH z%wGKAY9$~qv=lKOj;NY@!Dlq|&X2^C!8j&H(yY|q6eSA}3lk~95gm03fOPU;DUh-w z-R|)H1rtZ(W2V40zjK%OHzQ!y-k55{Y|hobp!oyC&0tb+)aN!_2;*byU$23fb%eR1 z`>_46bM|H=#Tk1rajVc)z07%%euMPZM|M#ny+P6W;~l)>d&U4LEg))1%nMRGPr>GF zGNeoQtJCVb)PEYf;iIRISYm(hZ{OIV(^1}<;jOdo_H6YkudwL$tV6hbS+t!YmR@Fl zuqbCPt4@HOD3}#@aY&6UsccXsO|CMqiWa^AM-u5+h_tTJ4!j)v{p@9XAaAAOx+L!( zO-iV8QT|bP3sTdKNG+W0*7Lx7wHS|I(Y9phb!#l7V7~C*{PqZ)dZQ(F?M-aLaiMY_zk8&TFN)V!9nMk(ORn#?MBVrlr<7o8zeg zvW8Td@y4)U)<;LDO-U6>oK544DHkT7gUg06(^YFK>$1Yo<}purm@k?0Mws$XH7yOR zl^vURnp7&ae^XF+8_Nf+@wz!|UU)(J4WO9N+Y*R;84J1nZ&_w$`dUScWRaRfD;xc$ zEHo)e)>@W#(lb^Snnm=B_=#q<&t%n=rx8P!x+sGjWm4~ub&OsJVHXp$lkLd3-b#># zd-MKtl^6;b1L!$yas4I!1j z1n$OO{mzfBB^UMR;;8_Flto(f{f!%!#Pn}5q>PyBvu=42s8yVy@+f?!pQAsq^UFQm zBSDMJOZ^G&w3!u_4)K+jYv@R9FvU7mr=kL8rjlM^+cy1O8t;D!5(6t-1$E~TyNyFd z>b?aw@wr^nmoZeS0MZS0l$&&Zl}rfEiCYvacB76|{8F82+)>VvDbnf)8*cTHiM}aC z?};4$47;vvE#70Hdb>>(4f)N$b<$8Jct=sg$BQd~1+DK;mIw%Z4v-03)+@`$^LS9Y zEWPwDroOa>r^vP+A9($rpMj4^hPosG8~N?%cICJj<{2 zq^JJID2&h1y5Cwy!)zEUN(%IS>5d%h8}yvC=3NK9h2%1BY@X*}>D}rZm-=0p zPX1nv*KYF6$;G-yko<%ezb!ww-z2(zWc_V|e1%FWq2*OZY-Q_3!Y5D+@YDK(QZ*(| zvB^^`-2W^#B@9WmBZ7%`Dq_aXQ|!8}z<8)!jyt_;gWAT*s_AR~QMvl-2cb4W1i_*} za#@(jR6{E=S2svXEY%8~Nm-t1dLE8QoonFD9b>oRGH;o>IN>Zgf~j8D=sl#c_Wtx&7FkaQAD=u*bRXW!%! zsRvqZWIa;TD}WDRPNb#%s=GO`WAD|L=>W+K?2A~G-xh~fH{~us+%)cTBEFsA@FxoI zmuwU9Py%a2-8>6WwD0Y;o*Wp`VDp( zV5A7_UG20cTDB+x?S2hNj}KCCv2}dPlU{D;o(QtHY7l!n_||qkR@F$V=W78f#j!a# z{WGj9Cl~`4)TjZ2?rMA5aOs>KS*o~9Z6$VlXJ0RG#Xrv|tE9ko3%Nuq1@MS))~tP0 z8f1Z9pOse=4@&v*tPffx?gln6TI%34Ov0+`E}_NJ+tcuyLNIppzt<7MgLK+?^kuo- zD&FAxrqq``rLOxHrF2mVHeYG-_s+PW%7OxIFX#Edsq;9x{{u1qH`M(1`^yd>hYD<> z8)n1N(aaBSXi$Co#ML%V{>oSNQseU$vaYVld$0Gq#@HV$Yo!DGS~!g`zq)MaBO=H~ zUmuXCdUOE7=hrdv>)bLV7Ey(<=55mTO8%jhdj73b1{towr~^Sqs)N^NJ+>zDog!CJ zeK)VI*YGZPL|tnd4#QV-keW;#$oimmj##fkp+^fzex#x_f4LSX81)h9vRejr(s>Dm zF3WNO=;!l__1ELy+xuYG`AiVY7 z$^3oW`ku!(jq{&Q@J~1R|LVNg0l;M-O3wwRgc06VbOZ9|8BZXM`e!t_9Og(uvsl6y zQ^mRt$SbSGUT;{B;vSsZ*fQ$hM}noTIE(PVwU$&bLd=Xs%vZka)!Ehk_zE6^(QC;Y zI+W2`s(&0ZpWq%AD^M)!2rNI8x4`z-0GYx|o=%!+CF@)jeX+6CvC1ELhv!|isO!~M zxKlDeU8tD{=Cu(Ns_1A>_&(1ctS-ildreNjD%|h;S_jkyfley>elGmiZO2ENv~J(N zvHzYXFE3x9Lr;L;qOPVEpFx%+^J?^S7dvEURx64F(F5v|(a(pLOMg%>IB_mgX*pqt5SoqgqOCVjsYKrR@_J}7 z^iacW?;{T8m!``pcnq}7+iu5FEwT=XqOskDsco2fiJ*6;I-5kh#x|A3W8<}BUe|rJ z#+RHtIz<=PD45$}ni`3A%DY^2)L)j4-}hgv^bcP8zpEXOKZa2luiK=z zTdk-!e&8hLu};VysQSMj%>VuqK^Pp$uKOh1__3WhG=Xb5cn+g3+u)7mC>hc8(=SCB z3tnw4i*)=!=2kVxPpJbOy@ydvCZ_&^*6!BrMTfaZ7S52$`l0wk+Y<bt(X?PLHooYLU>^Pv{mkKL2AcI$;- z7lQu}obfOA*znj9|9g2J1D~<9DjSvMmm&TevF&wW0pfybU|~bdH-xke9!qyf;Ay&D zx&>8S?r0YbF6s#2Co(j@y4;x>aRyH(9!Egx8l5u|3-AXL>3UHP8 z56l70${og%U*4lqL@^7-p94Pl-}o?kxT-+)^~H7x@KYWs+x@Ji)Ln*u`D*w$XHIMxf;bJ$7!Fh z0%H~Z(&DJnvR&nfD^B?Ae8X(taRo1=lbp&^eHohWzj70lcz@H?bkWuNwsKxP^7!=h z3>nCAStx$kQe#eaUMb<=bv>qshBeo}z0A+H_hsoeLXs*CEtdYu-)Px3Pz`9ioX`^s zSO+Mj@y`{_FD(CT#f}U2s%S7P6L3mKj!)$kYrcdd|3cFQPx84D;B!`!>}pM17yww1Ti~KT0U#C zXmnltwWDll#(u{frB4he9KCJHa;e`VBoa++iBs1rILRfP6HqQUUwpNw@HN>&$!@!T zU{HAWE**U&t8qNi#`gJKRmaxVN+DmOA(63>6!hrT)iIDHeE;*tRIsC>=b~&vU-H`x zvHKtow@0?1tnnxUg>se=Z;n zt+TXrh;|4z{N?E7%qQFD^(y<$=PEeet~T7zD>j;M%=1#SL5xwAhanf~4~pgC(NP2W z%YKOKKd}>Q0~l?)X*S3C^(}G}JK9}u_HVZi zUK4ZV3DdfXFWYotHCs_a<3vP6k`~!WYU7p?gYO0o7o0NRqrWVP`UQtIyv5%ww4F9Q zKFaZ$KU;b|Ukqe)BA~w4E&Kk9MU^bI+$$n*9hwqFi>ccMOI0SoA^SYHhPSIs2r*gA z?pGqA(}jCW#DgWu5xL z%GW(i2y|~S9!QfV^Pl%3NfHUbQgO7D61v$sg&}%Ll}Vka2d4@vm^3(=COBg;y2600 zq{{n$I3Vc3N0G$+Z)ve@spyy4=vPbA{>QYDTIsbX)W;wr)8 zu+`Yz>_d22mLaO%mvO^tfv)Z7nN*{NGq7YdiXi(Yvlfm#{P~63fC%ssN zSqYk>>OFNgMxPQTW@wfv0HgXx6@}1&tEwS_n>+?5aSJiX1Z562eV4h%v zI`wQ#G>6bXZSa&akm5`((SZqZzeMO4aHH2(N+T$;ybPD0zrV--z0Q7xJ;ahYsiLM! zv}Ss^%=)HDFB6@dYiV5AK2|Byp+LM}u&g8wKihISuc6C!>0R54U)FbaPiQjW%eEXZ zNH>tQPDL|lZilD|7PD9H#p3W=rXQ?XIlK<(E~~2(z{q8TLo4{^j}>P!6ko`Z310{l z^aaLTKB2-b>E#=GR*P$Eemmiq7j{G%IerT@LoQT5Om^Q z6Vq>Q2fiVO)3cEutd!Dum$f>d&7Nj0#EREL&Ak+pzCm+buRMWoj|-WG;GQjI{+>_D zhJOLVuwYKMh=(V!m#mK4hAJ1PB{shurdxkZZ}Ar`h* zmu$`Y$L56UCDrf(p%W>lxuAZL^Dn_Z`vlpMY{8?azpo|hq&-$B&(77Z+t4?SAPdU3 z7E+@Q8k;DD630!1Xuxc_uNPiPOB1NA!EXNSi&de9dUW+>pUm_|uI%S4L$LGhQWd*s z)5{}o+gWGVqH+*@TTzs2S2?e%GP|5j$IlSv%*6Ta56jgx8e+4sMy&|C!GVj1wp+Up75z6msFRpGQDwoD<5}ZxjW7gcwX4<*r z@_%=faww*^P84x}>zHRmrG=-3XH%`IZ$YFU#h@UOMHI&XkUQnd@2KL;J8uSXpUYu2 zI;$Ie(}zuT915zM?9XwF5M_#21wRNCXo z*3YFC_9S6z2UY)6`3YEO_(WKdCOQO7G7+NYzhm4l;wGXIPfEahiglpoRO;%c5$A^> z6S;nhDFEV_$g4Vz$36y{>C5A6Y__Y21`=p}h^Ct=O!poB?j(V6*X-)44nO{tfQ4}H zuTgB~m*7WCkf6cZzRCzrnFd`1<@R*rc7uK!JdIQheAblMo<9b6p?o<(r1Vq-JCJ%K zNA{T^k=LXYObHk;M!DUA6&c=cD^vH@VERi5=J6hk6r0I+;?7o`rZz(c=N!_Z(&NpC zn=8GpSPXgUlqw-P zLTYkIbp=?56wXD@2hJBqiwtn}cZbK4gg=v|nUAVbPSiqRjuGKMeiaLGg@=$DOdIZX z^8@nhubi{ybeYZ9bHCWF!~Gt{pl=N7cD1?NZSVR)8|5Xxa`i++;cV8x2a1vQkiN#Z z_B+w)Jj>S=`X)jRHMQt{fQBBEo}V2bJHK7pyY~_66USU8CWg)Aid3nm{UI(~9SDb+ zM?K9W%GR+UcwV*nvG@KFmEe0eRM7_UTDu~vl304U+Iw0U5? z|ABf=E|R*fe6w%4W?A!7?P_i9U%M{id$@N`Rb!p3Q}lvOyBvdfEhrp$Cy93}5vLyu ztv^Kh9Gj5WML@`CDmFL!Y#PLx1UCo)oiORb9${xpBDK9zGg%_!SoInoW(YdZ5uVPw zp2H0t@gU@H=Sb-sX1`2p8QUIx&YBqg!wV2l39S7X=dU2x?iC#PHlu(jsMfnFu!>41 zs($_L^^VuUv12StWI2bp^K;uoy!)`T;F*i5-w(dc5B21I!d7sTkK*}L3pDU0%xO#d zPwnxAH?lsrmFFhO%WxghD!j`x-&m9?YB?=yA*~LYdR!Uv81lnYNrI+Rm3KkIP7?xJ zke_>y9s9OvU&oi5+p_g%$SD*OUcWEaXKW(%v_yR#@Q7c-adEpj%srloRtR-*zT5Uq z>5Z1})s8C8a184>)YwD)(>VwQig!T~+O+?6lrf1~PO zn;bWy{aRP&S^Xdgj{Sq{H(=RKeVn-Q2Myz1P1|%5R%7+VR$SmS-aUG`NOR& zR0DzB(T2zbgh@%lQ@D@KLxsa8h$}4JSpH6B;DVbYKFEB<^7YbZOx8gdBLo&%#br($ zvbOSy{ME6FU|`xKay|*pT0C8d-e!yT{--GA7#l9bVT`$yJh!7 z+7zu04;kim+UnS!$G#q_<`Ba zqylw*O%N^;{dY$N)J^sBQ7v}$#)Hd!t^gDUvxCSp628)Qj34q^&989 ziBxBsG^JpQiFH6$kvw8x0$@jSaethm$D!Q@$N;-WMG(62nj;&AnZ){tu#5G@Ly+$) zIXB!5(yAhMYnu4t;e-p#bTk==X6C7|@)fdASbQ1_JN^WtsFx%zU31h$*Pz7H>b=aC z@|JcQp4{nW?;Eh8c9jILnI7YEv>B>fcRQ>g1{eug zMV$%yef0!9L@w|7;P=0R>BB$yFXh3traBg(ZBSCX_oe#nTn1wdK~S$MIa_WLyfT8{ z9Y)+FQisu8rEHT9PCfaXw~B%hwcWmWRoaP0B2BIs;olF3gIiB^)7?~P`TUd*MneTgah2tv?JOda4K&YRL56jT?zN_u}UE4E@HY(4e# z`?0hdx=pmRqf6aR^8Ht3hExUhR9-}d<)#cq78E+F(; zV6Xl2mc7HcIHvH~SFCN&a^q6Gve>3(or0O!E%cx@DEhpKdDK$bpupClE@B;Zlk+kX zB6@6e2nMXEHpt|GM9+;LBT7Z*m+jid#`%w!=M)YQ5{xtP68D?(mdC~3U>EQT?JX@% z@6Xe5b`2muA>mw(Jv}Qh{g=~!?&Q~k(P+nnaQ{4WAk(bFI$~Nc<`3P#iY9NH6`n-4 zN$e}f?cKR9&AW_Lk@9N-rx9IW@7A1}R{h@K8a?M2m#ckS zZd~n~(hhao?hI{rQOgwv1##KUkNHcploNg4^kqhr1rMl!ct}Api?aW6HdmwmM>}Wm zR|ig}s_6GWi>U0ervmi7`%_)-GTk6hL*SLFty~Jw38cYuneRb`n{@f#dB{J!%_0ku zEAq-y9e6Bt z;>k73Y)B=^mfA}9>Q>{D!{m)vk7N?Z{Dfk>-s z*i|rw0cNYcnUk~{*##TMM^RvdjS29(4bVIzR*zWdSJ*g_^{2(%T9t099JCFJSHdZ~@|aybHj|4vZGHV+k5T(j2z^XW#@zk8 zYlnR)r-Al(a!FmfS%3U4wpIY_hh&=qYtkHz#Lr4tuE^gd&s}FEBMEap@O<6HSqD|K zl{pZ|UHt+++?yW9SMiDe_z3f9u7PTY&Mt3mxM6YoaY&Hdgi1QfvD4N1h1+g%-@Isf zyQ*HBLOm4_hO$Ms$o~*PECw_7F|r0(N$KGwFeHr}{mL;JoUJ|1G zvopUlT^J{)%%b0PR3Lt!Pp_2>l{z9MHAMkV93#Hn5h`DUpm5q$;vzRI5p7t$YG9UO z8_t})oM#Xm5e^g@=GuVF&2BuipH$$hCBx4HP+7mT3<}X~tOan9q+{4*l;v&ekkx86RHuYyFw@0_zB)w;;o_|Tv zdTs$nziM|o(nbrIfUolfh{au;>={Cc5SvX)a%R$G$lWZHShYjiiuAd%PQ-J=u5HKw zrc=I%6xL@9)m}-=bs<@Xhh9?Iqjz6}(c^Pn`{LBU?J84X+~`;^#%Uq}hL%Xw`m%D+ zvPanszu7Y4cFSnmRe^JGD*P=9T(k-|q^L&bV!S`m63v(Ps!r7?v66B&Zf}OX6v}vb z1l8Mn>8lBGpt^yFWO%O=!Eylmp{vqN`Q6^LI==Q-7a~+qb(q7?q}e#di4Q0qmQSFV z_5LZFA%b6Jbl;YmDzC`Xn&!C zI0pG`fOztW3dVABGQ;;bGat(O$MyKVET_iXbiiy0ohu(Ro6Kc4IL{x!y&`{Y4_TKFJrx5B@Ewz!_d zckBcE;f0TBG^onW;7Mezh@_kMC}5q2%zU00#jpP*&Fhew{E+4-$V8V-mzJ00A_GSD z;xYkQRB(Aq>t8sq+2g;wBW5}TcegB8%uEDN0Yj6#;@2fUa8QZwQ9>WNv6cJR)|T(T z#!yCXB+Wa?e9QR?Wa5IgDDg+wE$Yl*Y-FFIkXF0*>G1)irg1sJ)slpYmM-5ManF2V zupghOy7=xr>xxEz3Ttt6_eBR}JI; zfnqeDLjhTGLmI2#3=kQLSWJU#I~W!5@uClz1%r$onWp+DmKC=?-FgI-+`}H20!14< zyFVr0f%HSYU{KNsK|V$^hZV*MZbN!qt_?<9{&8d91nY#gpjY^!ryvp^t-(sd#1Uvb znOPsd{`pkjhhifx2mhQv^qahPIhccH`ZFvEV3vU~2{HbsWIDEAB>W`u?mkZC!DrZ7 znL+%Rt$n4+F(chf;%IlaVdvO$aU1cX%KNIT4e`GaMS6W1c<0l-KGK2+{GQe zh}1F@+QV)Kz22dFo625i3%^~Dt@T_rw@Cue`NekFEFOcC;Jp>C#`0g5E8V}kz1+y@ zHF%_p++3?c;1YU%z59W5{xt_9+C&-`sUA*A)l`G~;JiJ^^5WN@w7C#Jf46UIs=5*Z zq?xbf?}PEaRani*kJOhp$+_UeMtX|Kixjoq)6?ABRn>EzwL@*kDOC*&Sk7y-j@lHQ zbBFrVCh6E`L(sHl%jxRhCKKRfO$@Spb_s8TCVcvz{FOViQtZcXa+R#>5Z!yWcGqEd zFFJ;`VIJ9C`n&$#h`ok(JWTUFjilTA9_iisReB~@dBA>;{7P+Q$UR9%^n+xH$@}c9 z%%l%A>3bQPalV68d@-pGNGDP(Hx4aP@oJq5^i^uekW$<+qzio)wqx{?GFBJ zA=^u_V*Nx7A(uJ-y{?8{eoaD?gWAqr%>G8y(PFqixqd5 z;_hxI|FzaWv-g~RCNs&*`zc?Z_qpY|eiwF)(>%FhdumubM>FptixIBYsl|o!@owVP zR N!K=*zk+v9e1yKmN{xj^K0f5>f;(& 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≪cr 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

vEct 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 &`A|WVEm)a2^`op=Ha$C%>FP3P?lk~fdE~9D2 zL^e@f6cL4Jtr zWK32gM5}JU5plZK0j9B|#d~q4+g5FU_BDe&RY2H_B1M(;8saY;*X!Yd`*~o!^m59= zml`^N4&u}sS$5@=Z?OUuan(PqIEQU5Z)#W+IFIVACQ5d)U^D*7;C>HJpD3XJh{fo~ z`RVi|HXl*r-rmm_7>>&0sMnNM#7kDKz^q&O=^|!kT5*;u()iCXJ5qIF(jF~g0>H$u zyUebe-ER@&+a8=|p~iRCBHfODyNC1+wWe|E=ht zFQ`1mjU)=RAtg=}vZkf2IU(A?CK@NRoxOQO2bdbXneQ)~`$C`l;r<(%lUSd_60voj zB&ma@ZGyMOXFh)Cm;f$f`?h~!NnR?xP8ve*1YQ-_i!IG%EFN;y zP^!7P@b<~_>vi&b+Pdl2EUQi08vb1MgH@?ft8Xt$;78>gPLfu8#_A;RpKG+XJ=>O4 zJsmrxhI>Egv}Q4XH7Za0bh*sDeaY`QfJLNga$eua*?H~F%obA_pJWa>spC?}

_JUB#m3Z+ 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 Cth_}4 literal 0 HcmV?d00001 From 3e29c664ac39d1cd75420fb244daf9b2e05b98ca Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 27 Sep 2023 11:41:02 +0300 Subject: [PATCH 003/294] core: remote host/controller types (#3104) * Start sprinkling ZoneId everywhere * Draft zone/satellite/host api * Add zone dispatching * Add command relaying handler * Parse commands and begin DB * Implement discussed things * Resolve some comments * Resolve more stuff * Make bots ignore remoteHostId from queues * Fix tests and stub more * Untangle cmd relaying * Resolve comments * Add more http2 client funs * refactor, rename * rename * remove empty tests --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/simplex-bot-advanced/Main.hs | 2 +- .../src/Broadcast/Bot.hs | 2 +- apps/simplex-chat/Server.hs | 2 +- .../src/Directory/Service.hs | 8 +- package.yaml | 2 + simplex-chat.cabal | 18 ++++ src/Simplex/Chat.hs | 62 +++++++++++-- src/Simplex/Chat/Bot.hs | 2 +- src/Simplex/Chat/Controller.hs | 91 +++++++++++++++++- src/Simplex/Chat/Core.hs | 2 +- .../Migrations/M20230922_remote_controller.hs | 31 +++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 14 +++ src/Simplex/Chat/Mobile.hs | 23 ++++- src/Simplex/Chat/Remote.hs | 92 +++++++++++++++++++ src/Simplex/Chat/Remote/Types.hs | 46 ++++++++++ src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Remote.hs | 28 ++++++ src/Simplex/Chat/Terminal/Input.hs | 2 +- src/Simplex/Chat/Terminal/Output.hs | 2 +- src/Simplex/Chat/Types.hs | 2 +- src/Simplex/Chat/View.hs | 3 + 21 files changed, 413 insertions(+), 25 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230922_remote_controller.hs create mode 100644 src/Simplex/Chat/Remote.hs create mode 100644 src/Simplex/Chat/Remote/Types.hs create mode 100644 src/Simplex/Chat/Store/Remote.hs diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 04d8e4ffa1..8d596c9707 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -41,7 +41,7 @@ mySquaringBot :: User -> ChatController -> IO () mySquaringBot _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc case resp of CRContactConnected _ contact _ -> do contactConnected contact diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index 04b6627f38..afeb116f7c 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -35,7 +35,7 @@ broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO () broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc case resp of CRContactConnected _ ct _ -> do contactConnected ct diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 6f198340f8..d32005e573 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -84,7 +84,7 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do >>= processCommand >>= atomically . writeTBQueue sndQ output ChatClient {sndQ} = forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc atomically $ writeTBQueue sndQ ChatSrvResponse {corrId = Nothing, resp} receive ws ChatClient {rcvQ, sndQ} = forever $ do s <- WS.receiveData ws diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 46abc4652d..7ed39847a0 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -59,7 +59,7 @@ welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" opts@DirectoryOpts {coreOptions = CoreChatOpts {dbFilePrefix}, testing} <- getDirectoryOpts appDir "simplex_directory_service" - unless testing $ do + unless testing $ do putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" pure opts @@ -68,7 +68,7 @@ directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User {userId} cc = do initializeBotAddress' (not testing) cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc forM_ (crDirectoryEvent resp) $ \case DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole @@ -161,7 +161,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { badRolesMsg :: GroupRolesStatus -> Maybe String badRolesMsg = \case GRSOk -> Nothing - GRSServiceNotAdmin -> Just "You must have a group *owner* role to register the group" + GRSServiceNotAdmin -> Just "You must have a group *owner* role to register the group" GRSContactNotOwner -> Just "You must grant directory service *admin* role to register the group" GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group" @@ -352,7 +352,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { groupRef = groupReference g srvRole = "*" <> B.unpack (strEncode serviceRole) <> "*" suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." - whenContactIsOwner gr action = + whenContactIsOwner gr action = getGroupMember gr >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) diff --git a/package.yaml b/package.yaml index 406f9aabaa..5c8c11a698 100644 --- a/package.yaml +++ b/package.yaml @@ -19,6 +19,7 @@ dependencies: - attoparsec == 0.14.* - base >= 4.7 && < 5 - base64-bytestring >= 1.0 && < 1.3 + - binary >= 0.8 && < 0.9 - bytestring == 0.11.* - composition == 1.0.* - constraints >= 0.12 && < 0.14 @@ -30,6 +31,7 @@ dependencies: - exceptions == 0.10.* - filepath == 1.4.* - http-types == 0.12.* + - http2 - memory == 0.18.* - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 338346b655..eda9c371d3 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -113,6 +113,7 @@ library Simplex.Chat.Migrations.M20230903_connections_to_subscribe Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes + Simplex.Chat.Migrations.M20230922_remote_controller Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -120,6 +121,8 @@ library Simplex.Chat.Options Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol + Simplex.Chat.Remote + Simplex.Chat.Remote.Types Simplex.Chat.Store Simplex.Chat.Store.Connections Simplex.Chat.Store.Direct @@ -128,6 +131,7 @@ library Simplex.Chat.Store.Messages Simplex.Chat.Store.Migrations Simplex.Chat.Store.Profiles + Simplex.Chat.Store.Remote Simplex.Chat.Store.Shared Simplex.Chat.Styled Simplex.Chat.Terminal @@ -151,6 +155,7 @@ library , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -162,6 +167,7 @@ library , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -199,6 +205,7 @@ executable simplex-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -210,6 +217,7 @@ executable simplex-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -248,6 +256,7 @@ executable simplex-bot-advanced , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -259,6 +268,7 @@ executable simplex-bot-advanced , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -299,6 +309,7 @@ executable simplex-broadcast-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -310,6 +321,7 @@ executable simplex-broadcast-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -349,6 +361,7 @@ executable simplex-chat , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -360,6 +373,7 @@ executable simplex-chat , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* @@ -403,6 +417,7 @@ executable simplex-directory-service , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -414,6 +429,7 @@ executable simplex-directory-service , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -476,6 +492,7 @@ test-suite simplex-chat-test , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 + , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -489,6 +506,7 @@ test-suite simplex-chat-test , filepath ==1.4.* , hspec ==2.11.* , http-types ==0.12.* + , http2 , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e74eaa0f5c..d14ab59700 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -62,6 +62,8 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol +import Simplex.Chat.Remote +import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct @@ -204,6 +206,8 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty + remoteHostSessions <- atomically TM.empty + remoteCtrlSession <- newTVarIO Nothing filesFolder <- newTVarIO optFilesFolder chatStoreChanged <- newTVarIO False expireCIThreads <- newTVarIO M.empty @@ -213,7 +217,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, remoteHostSessions, remoteCtrlSession, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} where configServers :: DefaultAgentServers configServers = @@ -340,12 +344,14 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, mapM_ hClose fs atomically $ writeTVar files M.empty -execChatCommand :: ChatMonad' m => ByteString -> m ChatResponse -execChatCommand s = do +execChatCommand :: ChatMonad' m => Maybe RemoteHostId -> ByteString -> m ChatResponse +execChatCommand rh s = do u <- readTVarIO =<< asks currentUser case parseChatCommand s of Left e -> pure $ chatCmdError u e - Right cmd -> execChatCommand_ u cmd + Right cmd -> case rh of + Nothing -> execChatCommand_ u cmd + Just remoteHostId -> execRemoteCommand u remoteHostId (s, cmd) execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) @@ -353,14 +359,26 @@ execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` c execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) +execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> (ByteString, ChatCommand) -> m ChatResponse +execRemoteCommand u rh scmd = either (CRChatCmdError u) id <$> runExceptT (withRemoteHostSession rh $ \rhs -> processRemoteCommand rhs scmd) + parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace +-- | Emit local events. toView :: ChatMonad' m => ChatResponse -> m () -toView event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, event) +toView = toView_ Nothing +-- | Used by transport to mark remote events with source. +toViewRemote :: ChatMonad' m => RemoteHostId -> ChatResponse -> m () +toViewRemote = toView_ . Just + +toView_ :: ChatMonad' m => Maybe RemoteHostId -> ChatResponse -> m () +toView_ rh event = do + q <- asks outputQ + atomically $ writeTBQueue q (Nothing, rh, event) + +-- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser @@ -1830,6 +1848,24 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} + CreateRemoteHost _displayName -> pure $ chatCmdError Nothing "not supported" + ListRemoteHosts -> pure $ chatCmdError Nothing "not supported" + StartRemoteHost rh -> do + RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" + (fingerprint, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) + _announcer <- async $ error "TODO: run announcer" fingerprint + hostAsync <- async $ error "TODO: runServer" storePath sessionCreds + chatModifyVar remoteHostSessions $ M.insert rh RemoteHostSession {hostAsync, storePath, ctrlClient = undefined} + pure $ chatCmdError Nothing "not supported" + StopRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" + ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" + StartRemoteCtrl -> pure $ chatCmdError Nothing "not supported" + ConfirmRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" + RejectRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" + StopRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" + DisposeRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) @@ -5599,6 +5635,17 @@ chatCommandP = "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, + "/create remote host" *> (CreateRemoteHost <$> textP), + "/list remote hosts" $> ListRemoteHosts, + "/start remote host " *> (StartRemoteHost <$> A.decimal), + "/stop remote host " *> (StopRemoteHost <$> A.decimal), + "/dispose remote host " *> (DisposeRemoteHost <$> A.decimal), + "/register remote ctrl " *> (RegisterRemoteCtrl <$> textP <*> remoteHostOOBP), + "/start remote ctrl" $> StartRemoteCtrl, + "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), + "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), + "/stop remote ctrl " *> (StopRemoteCtrl <$> A.decimal), + "/dispose remote ctrl " *> (DisposeRemoteCtrl <$> A.decimal), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, "/debug locks" $> DebugLocks, @@ -5716,6 +5763,7 @@ chatCommandP = srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} char_ = optional . A.char + remoteHostOOBP = RemoteHostOOB <$> textP adminContactReq :: ConnReqContact adminContactReq = diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index ecd1659bca..c5c5ff7eed 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -25,7 +25,7 @@ chatBotRepl :: String -> (Contact -> String -> IO String) -> User -> ChatControl chatBotRepl welcome answer _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc case resp of CRContactConnected _ contact _ -> do contactConnected contact diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2c829e4a95..c6405990a3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -46,6 +46,7 @@ import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol +import Simplex.Chat.Remote.Types import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -173,7 +174,7 @@ data ChatController = ChatController chatStoreChanged :: TVar Bool, -- if True, chat should be fully restarted idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, - outputQ :: TBQueue (Maybe CorrId, ChatResponse), + outputQ :: TBQueue (Maybe CorrId, Maybe RemoteHostId, ChatResponse), notifyQ :: TBQueue Notification, sendNotification :: Notification -> IO (), subscriptionMode :: TVar SubscriptionMode, @@ -181,6 +182,8 @@ data ChatController = ChatController sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), currentCalls :: TMap ContactId Call, + remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts + remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, expireCIThreads :: TMap UserId (Maybe (Async ())), @@ -410,6 +413,18 @@ data ChatCommand | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) + | CreateRemoteHost Text -- ^ Configure a new remote host + | ListRemoteHosts + | StartRemoteHost RemoteHostId -- ^ Start and announce a remote host + | StopRemoteHost RemoteHostId -- ^ Shut down a running session + | DisposeRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data + | RegisterRemoteCtrl Text RemoteHostOOB -- ^ Register OOB data for satellite discovery and handshake + | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers + | ListRemoteCtrls + | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm discovered data and store confirmation + | RejectRemoteCtrl RemoteCtrlId -- ^ Reject discovered data (and blacklist?) + | StopRemoteCtrl RemoteCtrlId -- ^ Stop listening for announcements or terminate an active session + | DisposeRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session | QuitChat | ShowVersion | DebugLocks @@ -580,6 +595,17 @@ data ChatResponse | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} + | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteHostOOB} + | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} -- XXX: RemoteHostInfo is mostly concerned with session setup + | CRRemoteHostStarted {remoteHostId :: RemoteHostId} + | CRRemoteHostStopped {remoteHostId :: RemoteHostId} + | CRRemoteHostDisposed {remoteHostId :: RemoteHostId} + | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} + | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlDisconnected {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -616,10 +642,32 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False +instance FromJSON ChatResponse where + parseJSON todo = pure $ CRCmdOk Nothing -- TODO: actually use the instances + instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +data RemoteHostOOB = RemoteHostOOB + { fingerprint :: Text -- CA key fingerprint + } + deriving (Show, Generic, ToJSON) + +data RemoteHostInfo = RemoteHostInfo + { remoteHostId :: RemoteHostId, + displayName :: Text, + sessionActive :: Bool + } + deriving (Show, Generic, ToJSON) + +data RemoteCtrlInfo = RemoteCtrlInfo + { remoteCtrlId :: RemoteCtrlId, + displayName :: Text, + sessionActive :: Bool + } + deriving (Show, Generic, ToJSON) + newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -858,6 +906,8 @@ data ChatError | ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity} | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} + | ChatErrorRemoteCtrl {remoteCtrlId :: RemoteCtrlId, remoteControllerError :: RemoteCtrlError} + | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} deriving (Show, Exception, Generic) instance ToJSON ChatError where @@ -967,6 +1017,41 @@ instance ToJSON SQLiteError where throwDBError :: ChatMonad m => DatabaseError -> m () throwDBError = throwError . ChatErrorDatabase +-- TODO review errors, some of it can be covered by HTTP2 errors +data RemoteHostError + = RHMissing -- ^ No remote session matches this identifier + | RHBusy -- ^ A session is already running + | RHRejected -- ^ A session attempt was rejected by a host + | RHTimeout -- ^ A discovery or a remote operation has timed out + | RHDisconnected {reason :: Text} -- ^ A session disconnected by a host + | RHConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues + deriving (Show, Exception, Generic) + +instance FromJSON RemoteHostError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RH" + +instance ToJSON RemoteHostError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RH" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RH" + +-- TODO review errors, some of it can be covered by HTTP2 errors +data RemoteCtrlError + = RCEMissing -- ^ No remote session matches this identifier + | RCEBusy -- ^ A session is already running + | RCETimeout -- ^ Remote operation timed out + | RCEDisconnected {reason :: Text} -- ^ A session disconnected by a controller + | RCEConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues + | RCECertificateExpired -- ^ A connection or CA certificate in a chain have bad validity period + | RCECertificateUntrusted -- ^ TLS is unable to validate certificate chain presented for a connection + deriving (Show, Exception, Generic) + +instance FromJSON RemoteCtrlError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RCE" + +instance ToJSON RemoteCtrlError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RCE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RCE" + type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) @@ -979,6 +1064,10 @@ chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m () chatWriteVar f value = asks f >>= atomically . (`writeTVar` value) {-# INLINE chatWriteVar #-} +chatModifyVar :: ChatMonad' m => (ChatController -> TVar a) -> (a -> a) -> m () +chatModifyVar f newValue = asks f >>= atomically . (`modifyTVar'` newValue) +{-# INLINE chatModifyVar #-} + tryChatError :: ChatMonad m => m a -> m (Either ChatError a) tryChatError = tryAllErrors mkChatError {-# INLINE tryChatError #-} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 4af161ab41..5f5a27e772 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -40,7 +40,7 @@ runSimplexChat ChatOpts {maintenance} u cc chat waitEither_ a1 a2 sendChatCmdStr :: ChatController -> String -> IO ChatResponse -sendChatCmdStr cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc +sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack s) cc sendChatCmd :: ChatController -> ChatCommand -> IO ChatResponse sendChatCmd cc cmd = runReaderT (execChatCommand' cmd) cc diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs new file mode 100644 index 0000000000..070a4e35cb --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs @@ -0,0 +1,31 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230922_remote_controller where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230922_remote_controller :: Query +m20230922_remote_controller = + [sql| +CREATE TABLE remote_hosts ( -- hosts known to a controlling app + remote_host_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, + store_path TEXT NOT NULL, + ca_cert BLOB NOT NULL, + ca_key BLOB NOT NULL +); + +CREATE TABLE remote_controllers ( -- controllers known to a hosting app + remote_controller_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, + fingerprint BLOB NOT NULL +); +|] + +down_m20230922_remote_controller :: Query +down_m20230922_remote_controller = + [sql| +DROP TABLE remote_hosts; +DROP TABLE remote_controllers; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 141247e590..292fcf32fc 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -515,6 +515,20 @@ CREATE TABLE IF NOT EXISTS "received_probes"( created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL) ); +CREATE TABLE remote_hosts( + -- hosts known to a controlling app + remote_host_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, + store_path TEXT NOT NULL, + ca_cert BLOB NOT NULL, + ca_key BLOB NOT NULL +); +CREATE TABLE remote_controllers( + -- controllers known to a hosting app + remote_controller_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, + fingerprint BLOB NOT NULL +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 700548bb12..664412c589 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -3,6 +3,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -fobject-code #-} module Simplex.Chat.Mobile where @@ -37,6 +38,7 @@ import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Options +import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types @@ -55,6 +57,8 @@ foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString +foreign export ccall "chat_send_remote_cmd" cChatSendRemoteCmd :: StablePtr ChatController -> CInt -> CString -> IO CJSONString + foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatController -> CInt -> IO CJSONString @@ -102,6 +106,14 @@ cChatSendCmd cPtr cCmd = do cmd <- B.packCString cCmd newCStringFromLazyBS =<< chatSendCmd c cmd +-- | send command to chat (same syntax as in terminal for now) +cChatSendRemoteCmd :: StablePtr ChatController -> CInt -> CString -> IO CJSONString +cChatSendRemoteCmd cPtr cRemoteHostId cCmd = do + c <- deRefStablePtr cPtr + cmd <- B.packCString cCmd + let rhId = Just $ fromIntegral cRemoteHostId + newCStringFromLazyBS =<< chatSendRemoteCmd c rhId cmd + -- | receive message from chat (blocking) cChatRecvMsg :: StablePtr ChatController -> IO CJSONString cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCStringFromLazyBS @@ -195,13 +207,16 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do _ -> dbError e dbError e = Left . DBMErrorSQL dbFile $ show e -chatSendCmd :: ChatController -> ByteString -> IO JSONByteString -chatSendCmd cc s = J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc +chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString +chatSendCmd cc = chatSendRemoteCmd cc Nothing + +chatSendRemoteCmd :: ChatController -> Maybe RemoteHostId -> B.ByteString -> IO JSONByteString +chatSendRemoteCmd cc rh s = J.encode . APIResponse Nothing rh <$> runReaderT (execChatCommand rh s) cc chatRecvMsg :: ChatController -> IO JSONByteString chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) where - json (corr, resp) = J.encode APIResponse {corr, resp} + json (corr, remoteHostId, resp) = J.encode APIResponse {corr, remoteHostId, resp} chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) @@ -227,7 +242,7 @@ chatPasswordHash pwd salt = either (const "") passwordHash salt' salt' = U.decode salt passwordHash = U.encode . C.sha512Hash . (pwd <>) -data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} +data APIResponse = APIResponse {corr :: Maybe CorrId, remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} deriving (Generic) instance ToJSON APIResponse where diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs new file mode 100644 index 0000000000..ba543e33f2 --- /dev/null +++ b/src/Simplex/Chat/Remote.hs @@ -0,0 +1,92 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Simplex.Chat.Remote where + +import Control.Monad.Except +import Control.Monad.IO.Class +import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) +import qualified Data.Map.Strict as M +import qualified Data.Binary.Builder as Binary +import qualified Network.HTTP.Types as HTTP +import qualified Network.HTTP2.Client as HTTP2Client +import Simplex.Chat.Controller +import Simplex.Chat.Remote.Types +import Simplex.Chat.Types +import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) +import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 +import Simplex.Messaging.Util (bshow) +import System.Directory (getFileSize) + +withRemoteHostSession :: ChatMonad m => RemoteHostId -> (RemoteHostSession -> m a) -> m a +withRemoteHostSession remoteHostId action = do + chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteHostId + where + err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing + +processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse +processRemoteCommand rhs = \case + -- XXX: intercept and filter some commands + -- TODO: store missing files on remote host + (s, _cmd) -> relayCommand rhs s + +relayCommand :: ChatMonad m => RemoteHostSession -> ByteString -> m ChatResponse +relayCommand RemoteHostSession {ctrlClient} s = postBytestring Nothing ctrlClient "/relay" mempty s >>= \case + Left e -> error "TODO: http2chatError" + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do + remoteChatResponse <- + if iTax then + case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> error "TODO: json2chatError" e + Right (raw :: J.Value) -> case J.fromJSON (sum2tagged raw) of + J.Error e -> error "TODO: json2chatError" e + J.Success cr -> pure cr + else + case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> error "TODO: json2chatError" e + Right cr -> pure cr + case remoteChatResponse of + -- TODO: intercept file responses and fetch files when needed + -- XXX: is that even possible, to have a file response to a command? + _ -> pure remoteChatResponse + where + iTax = True -- TODO: get from RemoteHost + -- XXX: extract to http2 transport + postBytestring timeout c path hs body = liftIO $ HTTP2.sendRequest c req timeout + where + req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) + +storeRemoteFile :: ChatMonad m => RemoteHostSession -> FilePath -> m ChatResponse +storeRemoteFile RemoteHostSession {ctrlClient} localFile = do + postFile Nothing ctrlClient "/store" mempty localFile >>= \case + Left e -> error "TODO: http2chatError" + Right HTTP2.HTTP2Response { response } -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of + Just 200 -> pure $ CRCmdOk Nothing + unexpected -> error "TODO: http2chatError" + where + postFile timeout c path hs file = liftIO $ do + fileSize <- fromIntegral <$> getFileSize file + HTTP2.sendRequest c (req fileSize) timeout + where + req size = HTTP2Client.requestFile "POST" path hs (HTTP2Client.FileSpec file 0 size) + +fetchRemoteFile :: ChatMonad m => RemoteHostSession -> FileTransferId -> m ChatResponse +fetchRemoteFile RemoteHostSession {ctrlClient, storePath} remoteFileId = do + liftIO (HTTP2.sendRequest ctrlClient req Nothing) >>= \case + Left e -> error "TODO: http2chatError" + Right HTTP2.HTTP2Response {respBody} -> do + error "TODO: stream body into a local file" -- XXX: consult headers for a file name? + where + req = HTTP2Client.requestNoBody "GET" path mempty + path = "/fetch/" <> bshow remoteFileId + +-- | Convert swift single-field sum encoding into tagged/discriminator-field +sum2tagged :: J.Value -> J.Value +sum2tagged = \case + J.Object todo'convert -> J.Object todo'convert + skip -> skip diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs new file mode 100644 index 0000000000..8e705d5d28 --- /dev/null +++ b/src/Simplex/Chat/Remote/Types.hs @@ -0,0 +1,46 @@ +{-# LANGUAGE DuplicateRecordFields #-} + +module Simplex.Chat.Remote.Types where + +import Control.Concurrent.Async (Async) +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) + +type RemoteHostId = Int64 + +data RemoteHost = RemoteHost + { remoteHostId :: RemoteHostId, + displayName :: Text, + -- | Path to store replicated files + storePath :: FilePath, + -- | A stable part of X509 credentials used to access the host + caCert :: ByteString, + -- | Credentials signing key for root and session certs + caKey :: C.Key + } + +type RemoteCtrlId = Int + +data RemoteCtrl = RemoteCtrl + { remoteCtrlId :: RemoteCtrlId, + displayName :: Text, + fingerprint :: Text + } + +data RemoteHostSession = RemoteHostSession + { -- | process to communicate with the host + hostAsync :: Async (), + -- | Path for local resources to be synchronized with host + storePath :: FilePath, + ctrlClient :: HTTP2Client + } + +-- | Host-side dual to RemoteHostSession, on-methods represent HTTP API. +data RemoteCtrlSession = RemoteCtrlSession + { -- | process to communicate with the remote controller + ctrlAsync :: Async () + -- server :: HTTP2Server + } diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index d8bab817e3..759244f019 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -81,6 +81,7 @@ import Simplex.Chat.Migrations.M20230829_connections_chat_vrange import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes +import Simplex.Chat.Migrations.M20230922_remote_controller import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -161,7 +162,8 @@ schemaMigrations = ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange), ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), - ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes) + ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), + ("20230922_remote_controller", m20230922_remote_controller, Just down_m20230922_remote_controller) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs new file mode 100644 index 0000000000..12fcb6c081 --- /dev/null +++ b/src/Simplex/Chat/Store/Remote.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Store.Remote where + +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import qualified Database.SQLite.Simple as DB +import Simplex.Chat.Remote.Types (RemoteHostId, RemoteHost (..)) +import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) +import qualified Simplex.Messaging.Crypto as C + +getRemoteHosts :: DB.Connection -> IO [RemoteHost] +getRemoteHosts db = + map toRemoteHost <$> DB.query_ db remoteHostQuery + +getRemoteHost :: DB.Connection -> RemoteHostId -> IO (Maybe RemoteHost) +getRemoteHost db remoteHostId = + maybeFirstRow toRemoteHost $ + DB.query db (remoteHostQuery <> "WHERE remote_host_id = ?") (DB.Only remoteHostId) + +remoteHostQuery :: DB.Query +remoteHostQuery = "SELECT remote_host_id, display_name, store_path, ca_cert, ca_key FROM remote_hosts" + +toRemoteHost :: (Int64, Text, FilePath, ByteString, C.Key) -> RemoteHost +toRemoteHost (remoteHostId, displayName, storePath, caCert, caKey) = + RemoteHost {remoteHostId, displayName, storePath, caCert, caKey} diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 8841f15ffd..1097a79543 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -56,7 +56,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do let bs = encodeUtf8 $ T.pack s cmd = parseChatCommand bs unless (isMessage cmd) $ echo s - r <- runReaderT (execChatCommand bs) cc + r <- runReaderT (execChatCommand Nothing bs) cc case r of CRChatCmdError _ _ -> when (isMessage cmd) $ echo s CRChatError _ _ -> when (isMessage cmd) $ echo s diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index db6f16f3ca..74bb9e8c0b 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -112,7 +112,7 @@ withTermLock ChatTerminal {termLock} action = do runTerminalOutput :: ChatTerminal -> ChatController -> IO () runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = do forever $ do - (_, r) <- atomically $ readTBQueue outputQ + (_, _, r) <- atomically $ readTBQueue outputQ case r of CRNewChatItem _ ci -> markChatItemRead ci CRChatItemUpdated _ ci -> markChatItemRead ci diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 93964316cc..a228055ad6 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -10,13 +10,13 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TypeFamilyDependencies #-} {-# LANGUAGE UndecidableInstances #-} -{-# LANGUAGE OverloadedRecordDot #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5db0c317e8..1f110fc951 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -297,6 +297,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRChatError u e -> ttyUser' u $ viewChatError logLevel e CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRTimedAction _ _ -> [] + todo'cr -> ["TODO" <> sShow todo'cr] where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser} ss @@ -1677,6 +1678,8 @@ viewChatError logLevel = \case Nothing -> "" cId :: Connection -> StyledString cId conn = sShow conn.connId + ChatErrorRemoteCtrl remoteCtrlId todo'rc -> [sShow remoteCtrlId, sShow todo'rc] + ChatErrorRemoteHost remoteHostId todo'rh -> [sShow remoteHostId, sShow todo'rh] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] sqliteError' = \case From 8709ad6ff3a3f44bec82bbbf93b0aaab5ade91d4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 27 Sep 2023 17:19:48 +0800 Subject: [PATCH 004/294] desktop: enhanced video player + inline player (#3130) * desktop: enhanced video player + inline player * simplify * simplify * removed unused code * follow up --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/multiplatform/common/build.gradle.kts | 2 +- .../common/views/chat/item/CIVIdeoView.kt | 2 +- .../views/chat/item/ImageFullScreenView.kt | 5 +- .../videoplayer/SkiaBitmapVideoSurface.kt | 79 ++++++++ .../simplex/common/platform/Images.desktop.kt | 36 ++++ .../common/platform/VideoPlayer.desktop.kt | 179 ++++++++++++------ .../views/chat/item/CIVideoView.desktop.kt | 19 +- .../chat/item/ImageFullScreenView.desktop.kt | 49 ++--- 8 files changed, 283 insertions(+), 88 deletions(-) create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 6a5fd1d0fe..9fb40c93d8 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -97,7 +97,7 @@ kotlin { implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6") implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("org.slf4j:slf4j-simple:2.0.7") - implementation("uk.co.caprica:vlcj:4.7.0") + implementation("uk.co.caprica:vlcj:4.7.3") } } val desktopTest by getting diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index 78bdf53d14..996dc819fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -127,7 +127,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau ) if (showPreview.value) { VideoPreviewImageView(preview, onClick, onLongClick) - PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick) + PlayButton(brokenVideo, onLongClick = onLongClick, play) } DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 05d11208fb..4d4d847cc7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -158,12 +158,11 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, player.stop() } LaunchedEffect(Unit) { - player.enableSound(true) snapshotFlow { isCurrentPage.value } .distinctUntilChanged() .collect { - // Do not autoplay on desktop because it needs workaround - if (it && appPlatform.isAndroid) play() else if (!it) stop() + if (it) play() else stop() + player.enableSound(true) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt new file mode 100644 index 0000000000..c2f37fd5d9 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt @@ -0,0 +1,79 @@ +package org.jetbrains.compose.videoplayer + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurfaceAdapters +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.nio.ByteBuffer +import javax.swing.SwingUtilities + +// https://github.com/JetBrains/compose-multiplatform/pull/3336/files +internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) { + + private val videoSurface = SkiaBitmapVideoSurface() + private lateinit var imageInfo: ImageInfo + private lateinit var frameBytes: ByteArray + private val skiaBitmap: Bitmap = Bitmap() + private val composeBitmap = mutableStateOf(null) + + val bitmap: State = composeBitmap + + override fun attach(mediaPlayer: MediaPlayer) { + videoSurface.attach(mediaPlayer) + } + + private inner class SkiaBitmapBufferFormatCallback : BufferFormatCallback { + private var sourceWidth: Int = 0 + private var sourceHeight: Int = 0 + + override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat { + this.sourceWidth = sourceWidth + this.sourceHeight = sourceHeight + return RV32BufferFormat(sourceWidth, sourceHeight) + } + + override fun allocatedBuffers(buffers: Array) { + frameBytes = buffers[0].run { ByteArray(remaining()).also(::get) } + imageInfo = ImageInfo( + sourceWidth, + sourceHeight, + ColorType.BGRA_8888, + ColorAlphaType.PREMUL, + ) + } + } + + private inner class SkiaBitmapRenderCallback : RenderCallback { + override fun display( + mediaPlayer: MediaPlayer, + nativeBuffers: Array, + bufferFormat: BufferFormat, + ) { + SwingUtilities.invokeLater { + nativeBuffers[0].rewind() + nativeBuffers[0].get(frameBytes) + skiaBitmap.installPixels(imageInfo, frameBytes, bufferFormat.width * 4) + composeBitmap.value = skiaBitmap.asComposeImageBitmap() + } + } + } + + private inner class SkiaBitmapVideoSurface : CallbackVideoSurface( + SkiaBitmapBufferFormatCallback(), + SkiaBitmapRenderCallback(), + true, + videoSurfaceAdapter, + ) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index b7341cb4a8..c04587656f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -6,6 +6,8 @@ import boofcv.struct.image.GrayU8 import chat.simplex.res.MR import org.jetbrains.skia.Image import java.awt.RenderingHints +import java.awt.geom.AffineTransform +import java.awt.image.AffineTransformOp import java.awt.image.BufferedImage import java.io.* import java.net.URI @@ -171,3 +173,37 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean { @Suppress("NewApi") actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap = Image.makeFromEncoded(inputStream.readAllBytes()).toComposeImageBitmap() + +// https://stackoverflow.com/a/68926993 +fun BufferedImage.rotate(angle: Double): BufferedImage { + val sin = Math.abs(Math.sin(Math.toRadians(angle))) + val cos = Math.abs(Math.cos(Math.toRadians(angle))) + val w = width + val h = height + val neww = Math.floor(w * cos + h * sin).toInt() + val newh = Math.floor(h * cos + w * sin).toInt() + val rotated = BufferedImage(neww, newh, type) + val graphic = rotated.createGraphics() + graphic.translate((neww - w) / 2, (newh - h) / 2) + graphic.rotate(Math.toRadians(angle), (w / 2).toDouble(), (h / 2).toDouble()) + graphic.drawRenderedImage(this, null) + graphic.dispose() + return rotated +} + +// https://stackoverflow.com/a/9559043 +fun BufferedImage.flip(vertically: Boolean, horizontally: Boolean): BufferedImage { + if (!vertically && !horizontally) return this + val tx: AffineTransform + if (vertically && horizontally) { + tx = AffineTransform.getScaleInstance(-1.0, -1.0) + tx.translate(-width.toDouble(), -height.toDouble()) + } else if (vertically) { + tx = AffineTransform.getScaleInstance(1.0, -1.0) + tx.translate(0.0, -height.toDouble()) + } else { + tx = AffineTransform.getScaleInstance(-1.0, 1.0) + tx.translate(-width.toDouble(), 0.0) + } + return AffineTransformOp(tx, AffineTransformOp.TYPE_NEAREST_NEIGHBOR).filter(this, null) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index e590c2e204..9f8c1884eb 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -2,17 +2,20 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.graphics.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* -import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.media.VideoOrientation +import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent import java.awt.Component +import java.awt.image.BufferedImage import java.io.File import java.net.URI +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.max actual class VideoPlayer actual constructor( @@ -29,17 +32,14 @@ actual class VideoPlayer actual constructor( override val duration: MutableState = mutableStateOf(0L) override val preview: MutableState = mutableStateOf(defaultPreview) - val mediaPlayerComponent = initializeMediaPlayerComponent() + val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } } val player by lazy { mediaPlayerComponent.mediaPlayer() } init { - withBGApi { - setPreviewAndDuration() - } + setPreviewAndDuration() } - private val currentVolume: Int by lazy { player.audio().volume() } - private var isReleased: Boolean = false + private var isReleased: AtomicBoolean = AtomicBoolean(false) private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null) private var progressJob: Job? = null @@ -48,6 +48,7 @@ actual class VideoPlayer actual constructor( PLAYING, PAUSED, STOPPED } + /** Should be called in [playerThread]. Otherwise, it creates deadlocks in [player.stop] and [player.release] calls */ private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean { val filepath = getAppFilePath(uri) if (filepath == null || !File(filepath).exists()) { @@ -87,7 +88,7 @@ actual class VideoPlayer actual constructor( // Player can only be accessed in one specific thread progressJob = CoroutineScope(Dispatchers.Main).launch { onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING) - while (isActive && !isReleased && player.isPlaying) { + while (isActive && !isReleased.get() && player.isPlaying) { // Even when current position is equal to duration, the player has isPlaying == true for some time, // so help to make the playback stopped in UI immediately if (player.currentPosition == player.duration) { @@ -97,7 +98,7 @@ actual class VideoPlayer actual constructor( delay(50) onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING) } - if (isActive && !isReleased) { + if (isActive && !isReleased.get()) { onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED) } onProgressUpdate(null, TrackState.PAUSED) @@ -107,9 +108,11 @@ actual class VideoPlayer actual constructor( } override fun stop() { - if (isReleased || !videoPlaying.value) return - player.controls().stop() - stopListener() + if (isReleased.get() || !videoPlaying.value) return + playerThread.execute { + player.stop() + stopListener() + } } private fun stopListener() { @@ -133,45 +136,57 @@ actual class VideoPlayer actual constructor( if (progress.value == duration.value) { progress.value = 0 } - videoPlaying.value = start(progress.value) { pro, _ -> - if (pro != null) { - progress.value = pro - } - if ((pro == null || pro == duration.value) && duration.value != 0L) { - videoPlaying.value = false - if (pro == duration.value) { - progress.value = if (resetOnEnd) 0 else duration.value - }/* else if (state == TrackState.STOPPED) { + playerThread.execute { + videoPlaying.value = start(progress.value) { pro, _ -> + if (pro != null) { + progress.value = pro + } + if ((pro == null || pro == duration.value) && duration.value != 0L) { + videoPlaying.value = false + if (pro == duration.value) { + progress.value = if (resetOnEnd) 0 else duration.value + }/* else if (state == TrackState.STOPPED) { progress.value = 0 // }*/ + } } } } override fun enableSound(enable: Boolean): Boolean { - if (isReleased) return false - if (soundEnabled.value == enable) return false + // Impossible to change volume for only one player. It changes for every player + // https://github.com/caprica/vlcj/issues/985 + return false + /*if (isReleased.get() || soundEnabled.value == enable) return false soundEnabled.value = enable - player.audio().setVolume(if (enable) currentVolume else 0) - return true + playerThread.execute { + player.audio().isMute = !enable + } + return true*/ } - override fun release(remove: Boolean) { withApi { - if (isReleased) return@withApi - isReleased = true - // TODO - /** [player.release] freezes thread for some reason. It happens periodically. So doing this we don't see the freeze, but it's still there */ - if (player.isPlaying) player.stop() - CoroutineScope(Dispatchers.IO).launch { player.release() } - if (remove) { - VideoPlayerHolder.players.remove(uri to gallery) + override fun release(remove: Boolean) { + CoroutineScope(playerThread.asCoroutineDispatcher()).launch { + if (isReleased.get()) return@launch + isReleased.set(true) + if (player.isPlaying) { + player.stop() + } + if (usePool) { + putPlayer(mediaPlayerComponent) + } else { + player.release() + } + if (remove) { + VideoPlayerHolder.players.remove(uri to gallery) + } } - }} + } private val MediaPlayer.currentPosition: Int - get() = if (isReleased) 0 else max(0, player.status().time().toInt()) + get() = if (isReleased.get()) 0 else max(0, status().time().toInt()) - private suspend fun setPreviewAndDuration() { + private fun setPreviewAndDuration() { // It freezes main thread, doing it in IO thread CoroutineScope(Dispatchers.IO).launch { val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(defaultPreview, uri) } @@ -182,35 +197,79 @@ actual class VideoPlayer actual constructor( } } - private fun initializeMediaPlayerComponent(): Component { - return if (desktopPlatform.isMac()) { - CallbackMediaPlayerComponent() - } else { - EmbeddedMediaPlayerComponent() - } - } - - private fun Component.mediaPlayer() = when (this) { - is CallbackMediaPlayerComponent -> mediaPlayer() - is EmbeddedMediaPlayerComponent -> mediaPlayer() - else -> error("mediaPlayer() can only be called on vlcj player components") - } - companion object { - suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration { - val player = CallbackMediaPlayerComponent().mediaPlayer() + private val usePool = false + + private fun Component.mediaPlayer() = when (this) { + is CallbackMediaPlayerComponent -> mediaPlayer() + is EmbeddedMediaPlayerComponent -> mediaPlayer() + else -> error("mediaPlayer() can only be called on vlcj player components") + } + + private fun initializeMediaPlayerComponent(): Component { + return if (desktopPlatform.isMac()) { + CallbackMediaPlayerComponent() + } else { + EmbeddedMediaPlayerComponent() + } + } + + suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) { + val mediaComponent = getOrCreateHelperPlayer() + val player = mediaComponent.mediaPlayer() if (uri == null || !File(uri.rawPath).exists()) { - return VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L) + return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L) } player.media().startPaused(uri.toString().replaceFirst("file:", "file://")) val start = System.currentTimeMillis() - while (player.snapshots()?.get() == null && start + 5000 > System.currentTimeMillis()) { + var snap: BufferedImage? = null + while (snap == null && start + 5000 > System.currentTimeMillis()) { + snap = player.snapshots()?.get() delay(10) } - val preview = player.snapshots()?.get()?.toComposeImageBitmap() + val orientation = player.media().info().videoTracks().first().orientation() + val preview: ImageBitmap? = when (orientation) { + VideoOrientation.TOP_LEFT -> snap + VideoOrientation.TOP_RIGHT -> snap?.flip(false, true) + VideoOrientation.BOTTOM_LEFT -> snap?.flip(true, false) + VideoOrientation.BOTTOM_RIGHT -> snap?.rotate(180.0) + VideoOrientation.LEFT_TOP -> snap /* Transposed */ + VideoOrientation.LEFT_BOTTOM -> snap?.rotate(-90.0) + VideoOrientation.RIGHT_TOP -> snap?.rotate(90.0) + VideoOrientation.RIGHT_BOTTOM -> snap /* Anti-transposed */ + else -> snap + }?.toComposeImageBitmap() val duration = player.duration.toLong() - CoroutineScope(Dispatchers.IO).launch { player.release() } - return VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration) + player.stop() + putHelperPlayer(mediaComponent) + return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration) } + + val playerThread = Executors.newSingleThreadExecutor() + private val playersPool: ArrayList = ArrayList() + private val helperPlayersPool: ArrayList = ArrayList() + + private fun getOrCreatePlayer(): Component = playersPool.removeFirstOrNull() ?: createNew() + + private fun createNew(): Component = + initializeMediaPlayerComponent().apply { + mediaPlayer().events().addMediaPlayerEventListener(object: MediaPlayerEventAdapter() { + override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) { + playerThread.execute { + mediaPlayer?.audio()?.setVolume(100) + mediaPlayer?.audio()?.isMute = false + } + } + + override fun stopped(mediaPlayer: MediaPlayer?) { + //playerThread.execute { mediaPlayer().videoSurface().set(null) } + } + }) + } + + private fun putPlayer(player: Component) = playersPool.add(player) + + private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent() + private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index c85057b47e..7a46873f7d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -1,12 +1,29 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import chat.simplex.common.platform.VideoPlayer +import chat.simplex.common.platform.isPlaying +import chat.simplex.common.views.helpers.onRightClick @Composable -actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {} +actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { + Box { + SurfaceFromPlayer(player, + Modifier + .width(width) + .combinedClickable( + onLongClick = onLongClick, + onClick = { if (player.player.isPlaying) stop() else onClick() } + ) + .onRightClick(onLongClick) + ) + } +} @Composable actual fun LocalWindowWidth(): Dp { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index 9aafc83d23..bd395c2c97 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -6,17 +6,15 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import chat.simplex.common.platform.* -import chat.simplex.common.simplexWindowState import chat.simplex.common.views.helpers.getBitmapFromByteArray import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import kotlinx.coroutines.delay +import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface import kotlin.math.max @Composable @@ -28,30 +26,40 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: modifier = modifier, ) } + @Composable actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) { - // Workaround. Without changing size of the window the screen flashes a lot even if it's not being recomposed - LaunchedEffect(Unit) { - simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width + 1.dp) - delay(50) - player.play(true) - simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width - 1.dp) - } Box { Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) { - val factory = remember { { player.mediaPlayerComponent } } - SwingPanel( - background = Color.Transparent, - modifier = Modifier, - factory = factory - ) + SurfaceFromPlayer(player, modifier) + IconButton(onClick = close, Modifier.padding(top = 5.dp)) { + Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary) + } } - Controls(player, close) + Controls(player) } } @Composable -private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) { +fun BoxScope.SurfaceFromPlayer(player: VideoPlayer, modifier: Modifier) { + val surface = remember { + SkiaBitmapVideoSurface().also { + player.player.videoSurface().set(it) + } + } + surface.bitmap.value?.let { bitmap -> + Image( + bitmap, + modifier = modifier.align(Alignment.Center), + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + ) + } +} + +@Composable +private fun BoxScope.Controls(player: VideoPlayer) { val playing = remember(player) { player.videoPlaying } val progress = remember(player) { player.progress } val duration = remember(player) { player.duration } @@ -62,10 +70,7 @@ private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) { Slider( value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()), onValueChange = { player.player.seekTo((it * duration.value).toInt()) }, - modifier = Modifier.fillMaxWidth().weight(1f) + modifier = Modifier.fillMaxWidth() ) - IconButton(onClick = close,) { - Icon(painterResource(MR.images.ic_close), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary) - } } } From 77410e5d5e434b209297dd4181668b515174723c Mon Sep 17 00:00:00 2001 From: IC Rainbow Date: Wed, 27 Sep 2023 13:40:19 +0300 Subject: [PATCH 005/294] Add remote host discovery --- cabal.project | 2 +- package.yaml | 3 + simplex-chat.cabal | 22 +++++ src/Simplex/Chat.hs | 15 +-- src/Simplex/Chat/Remote.hs | 3 + src/Simplex/Chat/Remote/Discovery.hs | 132 +++++++++++++++++++++++++++ src/Simplex/Chat/Remote/Types.hs | 4 +- 7 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 src/Simplex/Chat/Remote/Discovery.hs diff --git a/cabal.project b/cabal.project index b4024f088c..7d7339a7fb 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + tag: 681fa93bf342d7c836fa0ff69b767dcd08526f03 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 5c8c11a698..5337b7eecf 100644 --- a/package.yaml +++ b/package.yaml @@ -25,6 +25,7 @@ dependencies: - constraints >= 0.12 && < 0.14 - containers == 0.6.* - cryptonite == 0.30.* + - data-default >= 0.7 && < 0.8 - directory == 1.3.* - direct-sqlcipher == 2.3.* - email-validate == 2.3.* @@ -35,6 +36,7 @@ dependencies: - memory == 0.18.* - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 + - network-udp >= 0.0 && < 0.1 - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* - random >= 1.1 && < 1.3 @@ -48,6 +50,7 @@ dependencies: - terminal == 0.2.* - text == 2.0.* - time == 1.9.* + - tls - unliftio == 0.2.* - unliftio-core == 0.2.* - zip == 2.0.* diff --git a/simplex-chat.cabal b/simplex-chat.cabal index eda9c371d3..1af77b9112 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -122,6 +122,7 @@ library Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Remote + Simplex.Chat.Remote.Discovery Simplex.Chat.Remote.Types Simplex.Chat.Store Simplex.Chat.Store.Connections @@ -161,6 +162,7 @@ library , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -171,6 +173,7 @@ library , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -184,6 +187,7 @@ library , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -211,6 +215,7 @@ executable simplex-bot , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -221,6 +226,7 @@ executable simplex-bot , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -235,6 +241,7 @@ executable simplex-bot , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -262,6 +269,7 @@ executable simplex-bot-advanced , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -272,6 +280,7 @@ executable simplex-bot-advanced , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -286,6 +295,7 @@ executable simplex-bot-advanced , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -315,6 +325,7 @@ executable simplex-broadcast-bot , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -325,6 +336,7 @@ executable simplex-broadcast-bot , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -339,6 +351,7 @@ executable simplex-broadcast-bot , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -367,6 +380,7 @@ executable simplex-chat , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -377,6 +391,7 @@ executable simplex-chat , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -391,6 +406,7 @@ executable simplex-chat , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , websockets ==0.12.* @@ -423,6 +439,7 @@ executable simplex-directory-service , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* @@ -433,6 +450,7 @@ executable simplex-directory-service , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -447,6 +465,7 @@ executable simplex-directory-service , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -498,6 +517,7 @@ test-suite simplex-chat-test , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite ==0.30.* + , data-default ==0.7.* , deepseq ==1.4.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -510,6 +530,7 @@ test-suite simplex-chat-test , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* + , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -525,6 +546,7 @@ test-suite simplex-chat-test , terminal ==0.2.* , text ==2.0.* , time ==1.9.* + , tls , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index d14ab59700..afcd95443e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -63,6 +63,7 @@ import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Remote +import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Connections @@ -1852,12 +1853,14 @@ processChatCommand = \case ListRemoteHosts -> pure $ chatCmdError Nothing "not supported" StartRemoteHost rh -> do RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" - (fingerprint, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) - _announcer <- async $ error "TODO: run announcer" fingerprint - hostAsync <- async $ error "TODO: runServer" storePath sessionCreds - chatModifyVar remoteHostSessions $ M.insert rh RemoteHostSession {hostAsync, storePath, ctrlClient = undefined} - pure $ chatCmdError Nothing "not supported" - StopRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + (fingerprint :: ByteString, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) + cleanup <- toIO $ chatModifyVar remoteHostSessions (M.delete rh) + Discovery.runAnnouncer cleanup fingerprint sessionCreds >>= \case + Left todo'err -> pure $ chatCmdError Nothing "TODO: Some HTTP2 error" + Right ctrlClient -> do + chatModifyVar remoteHostSessions $ M.insert rh RemoteHostSession {storePath, ctrlClient} + pure $ CRRemoteHostStarted rh + StopRemoteHost rh -> closeRemoteHostSession rh $> CRRemoteHostStopped rh DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index ba543e33f2..34c6b31a46 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -29,6 +29,9 @@ withRemoteHostSession remoteHostId action = do where err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing +closeRemoteHostSession :: ChatMonad m => RemoteHostId -> m () +closeRemoteHostSession rh = withRemoteHostSession rh (liftIO . HTTP2.closeHTTP2Client . ctrlClient) + processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse processRemoteCommand rhs = \case -- XXX: intercept and filter some commands diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs new file mode 100644 index 0000000000..ace1ced313 --- /dev/null +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -0,0 +1,132 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Remote.Discovery + ( runAnnouncer, + runDiscoverer, + ) +where + +import Control.Monad +import Data.ByteString.Builder (Builder, intDec) +import Data.Default (def) +import Data.String (IsString) +import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import Debug.Trace +import qualified Network.HTTP.Types as HTTP +import qualified Network.HTTP2.Server as HTTP2 +import qualified Network.Socket as N +import qualified Network.TLS as TLS +import qualified Network.UDP as UDP +import Simplex.Chat.Controller (ChatMonad) +import Simplex.Chat.Types () +import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import Simplex.Messaging.Transport (supportedParameters) +import qualified Simplex.Messaging.Transport as Transport +import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportClientConfig, runTransportClient) +import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, defaultHTTP2ClientConfig) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) +import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) +import UnliftIO +import UnliftIO.Concurrent + +runAnnouncer :: (StrEncoding invite, ChatMonad m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) +runAnnouncer finished invite credentials = do + started <- newEmptyTMVarIO + aPid <- async $ announcer started (strEncode invite) + let serverParams = + def + { TLS.serverWantClientCert = False, + TLS.serverShared = def {TLS.sharedCredentials = credentials}, + TLS.serverHooks = def, + TLS.serverSupported = supportedParameters + } + httpClient <- newEmptyMVar + liftIO $ runTransportServer started partyPort serverParams defaultTransportServerConfig (run aPid httpClient) + takeMVar httpClient + where + announcer started inviteBS = do + atomically (takeTMVar started) >>= \case + False -> + error "Server not started?.." + True -> liftIO $ do + traceM $ "TCP server started at " <> partyPort + sock <- UDP.clientSocket broadcastAddrV4 partyPort False + N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 + traceM $ "UDP announce started at " <> broadcastAddrV4 <> ":" <> partyPort + traceM $ "Server invite: " <> show inviteBS + forever $ do + UDP.send sock inviteBS + threadDelay 1000000 + + run :: Async () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () + run aPid clientVar tls = do + cancel aPid + let partyHost = "255.255.255.255" -- XXX: get from tls somehow? not required as host verification is disabled. + attachHTTP2Client defaultHTTP2ClientConfig partyHost partyPort finished defaultHTTP2BufferSize tls >>= putMVar clientVar + +-- | Link-local broadcast address. +broadcastAddrV4 :: (IsString a) => a +broadcastAddrV4 = "255.255.255.255" + +partyPort :: (IsString a) => a +partyPort = "5226" -- XXX: should be `0` or something, to get a random port and announce it + +runDiscoverer :: (ChatMonad m) => Text -> m () +runDiscoverer oobData = + case strDecode (encodeUtf8 oobData) of + Left err -> traceM $ "oobData decode error: " <> err + Right expected -> liftIO $ do + traceM $ "runDiscoverer: locating " <> show oobData + sock <- UDP.serverSocket (broadcastAddrV4, read partyPort) + N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 + traceM $ "runDiscoverer: " <> show sock + go sock expected + where + go sock expected = do + (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock + traceShowM (invite, source) + let expect hash = hash `elem` [expected] -- XXX: can be a callback to fetch actual invite list just in time + case strDecode invite of + Left err -> do + traceM $ "Inivite decode error: " <> err + go sock expected + Right inviteHash | not (expect inviteHash) -> do + traceM $ "Skipping unexpected invite " <> show (strEncode inviteHash) + go sock expected + Right _expected -> do + host <- case source of + N.SockAddrInet _port addr -> do + pure $ THIPv4 (N.hostAddressToTuple addr) + unexpected -> + -- TODO: actually, Apple mandates IPv6 support + fail $ "Discoverer: expected an IPv4 party, got " <> show unexpected + traceM $ "Discoverer: go connect " <> show host + runTransportClient defaultTransportClientConfig Nothing host partyPort (Just expected) $ \tls -> do + traceM "2PTTH server starting" + run tls + traceM "2PTTH server finished" + + run tls = runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + reqBody <- getHTTP2Body r 16384 + processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} + + processRequest req = do + traceM $ "Got request: " <> show (request req) + -- TODO: sendResponse req . HTTP2.promiseResponse $ HTTP2.pushPromise path response weight + sendResponse req $ HTTP2.responseStreaming HTTP.ok200 sseHeaders sseExample + + sseHeaders = [(HTTP.hContentType, "text/event-stream")] + + sseExample :: (Builder -> IO ()) -> IO () -> IO () + sseExample write flush = forM_ [1 .. 10] $ \i -> do + let payload = "[" <> intDec i <> ", \"blah\"]" + write "event: message\n" -- XXX: SSE header line + write $ "data: " <> payload <> "\n" -- XXX: SSE payload line + write "\n" -- XXX: SSE delimiter + flush + threadDelay 1000000 diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 8e705d5d28..9f28eab551 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -31,9 +31,7 @@ data RemoteCtrl = RemoteCtrl } data RemoteHostSession = RemoteHostSession - { -- | process to communicate with the host - hostAsync :: Async (), - -- | Path for local resources to be synchronized with host + { -- | Path for local resources to be synchronized with host storePath :: FilePath, ctrlClient :: HTTP2Client } From 3c7fc6b0ee1949dbe731bc11ad4e4809474ae7fd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 27 Sep 2023 15:26:03 +0100 Subject: [PATCH 006/294] core: support closing/re-opening store on chat stop/start (#3132) * core: support closing/re-opening store on chat stop/start * update .nix refs * kotlin: types * add test * update simplexmq --- .../chat/simplex/common/model/SimpleXAPI.kt | 25 ++++++++---- .../common/views/database/DatabaseView.kt | 2 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 39 ++++++++++++------- src/Simplex/Chat/Archive.hs | 2 +- src/Simplex/Chat/Controller.hs | 15 ++++++- src/Simplex/Chat/Core.hs | 2 +- stack.yaml | 2 +- tests/ChatClient.hs | 2 +- tests/ChatTests/Direct.hs | 7 +++- 11 files changed, 69 insertions(+), 31 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 060738bc18..87cac179ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -516,8 +516,8 @@ object ChatController { throw Exception("failed to delete the user ${r.responseType} ${r.details}") } - suspend fun apiStartChat(): Boolean { - val r = sendCmd(CC.StartChat(expire = true)) + suspend fun apiStartChat(openDBWithKey: String? = null): Boolean { + val r = sendCmd(CC.StartChat(ChatCtrlCfg(subConns = true, enableExpireCIs = true, startXFTPWorkers = true, openDBWithKey = openDBWithKey))) when (r) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -525,8 +525,8 @@ object ChatController { } } - suspend fun apiStopChat(): Boolean { - val r = sendCmd(CC.ApiStopChat()) + suspend fun apiStopChat(closeStore: Boolean): Boolean { + val r = sendCmd(CC.ApiStopChat(closeStore)) when (r) { is CR.ChatStopped -> return true else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}") @@ -1829,8 +1829,8 @@ sealed class CC { class ApiMuteUser(val userId: Long): CC() class ApiUnmuteUser(val userId: Long): CC() class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC() - class StartChat(val expire: Boolean): CC() - class ApiStopChat: CC() + class StartChat(val cfg: ChatCtrlCfg): CC() + class ApiStopChat(val closeStore: Boolean): CC() class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() @@ -1933,8 +1933,9 @@ sealed class CC { is ApiMuteUser -> "/_mute user $userId" is ApiUnmuteUser -> "/_unmute user $userId" is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}" - is StartChat -> "/_start subscribe=on expire=${onOff(expire)} xftp=on" - is ApiStopChat -> "/_stop" +// is StartChat -> "/_start ${json.encodeToString(cfg)}" // this can be used with the new core + is StartChat -> "/_start subscribe=on expire=${onOff(cfg.enableExpireCIs)} xftp=on" + is ApiStopChat -> if (closeStore) "/_stop close" else "/_stop" is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" @@ -2151,6 +2152,14 @@ sealed class CC { } } +@Serializable +data class ChatCtrlCfg ( + val subConns: Boolean, + val enableExpireCIs: Boolean, + val startXFTPWorkers: Boolean, + val openDBWithKey: String? +) + @Serializable data class NewUser( val profile: Profile?, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index fa0f8f54d1..3eb2e7d734 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -419,7 +419,7 @@ private fun stopChat(m: ChatModel) { } suspend fun stopChatAsync(m: ChatModel) { - m.controller.apiStopChat() + m.controller.apiStopChat(false) m.chatRunning.value = false } diff --git a/cabal.project b/cabal.project index b4024f088c..b9753c9c00 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + tag: fda1284ae4b7c33cae2eb8ed0376a511aecc1d51 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26f4ea1122..faa2401b17 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8d47f690838371bc848e4b31a4b09ef6bf67ccc5" = "1pwasv22ii3wy4xchaknlwczmy5ws7adx7gg2g58lxzrgdjm3650"; + "https://github.com/simplex-chat/simplexmq.git"."fda1284ae4b7c33cae2eb8ed0376a511aecc1d51" = "1gq7scv9z8x3xhzl914xr46na0kkrqd1i743xbw69lyx33kj9xb5"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e74eaa0f5c..1626fe8fda 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -83,7 +83,7 @@ import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentCl import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection, closeSQLiteStore, openSQLiteStore) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations @@ -249,9 +249,13 @@ cfgServers p s = case p of SPSMP -> s.smp SPXFTP -> s.xftp -startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ()) -startChatController subConns enableExpireCIs startXFTPWorkers = do - asks smpAgent >>= resumeAgentClient +startChatController :: forall m. ChatMonad' m => ChatCtrlCfg -> m (Async ()) +startChatController ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey} = do + ChatController {chatStore, smpAgent} <- ask + forM_ openDBWithKey $ \(DBEncryptionKey dbKey) -> liftIO $ do + openSQLiteStore chatStore dbKey + openSQLiteStore (agentClientStore smpAgent) dbKey + resumeAgentClient smpAgent unless subConns $ chatWriteVar subscriptionMode SMOnlyCreate users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers) @@ -323,8 +327,8 @@ restoreCalls = do calls <- asks currentCalls atomically $ writeTVar calls callsMap -stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do +stopChatController :: forall m. MonadUnliftIO m => ChatController -> Bool -> m () +stopChatController ChatController {chatStore, smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} closeStore = do disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles @@ -333,6 +337,9 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, keys <- M.keys <$> readTVar expireCIFlags forM_ keys $ \k -> TM.insert k False expireCIFlags writeTVar s Nothing + when closeStore $ liftIO $ do + closeSQLiteStore chatStore + closeSQLiteStore $ agentClientStore smpAgent where closeFiles :: TVar (Map Int64 Handle) -> m () closeFiles files = do @@ -462,12 +469,12 @@ processChatCommand = \case checkDeleteChatUser user' withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_ - StartChat subConns enableExpireCIs startXFTPWorkers -> withUser' $ \_ -> + APIStartChat cfg -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted - APIStopChat -> do - ask >>= stopChatController + _ -> checkStoreNotChanged $ startChatController cfg $> CRChatStarted + APIStopChat closeStore -> do + ask >>= (`stopChatController` closeStore) pure CRChatStopped APIActivateChat -> withUser $ \_ -> do restoreCalls @@ -5379,9 +5386,9 @@ chatCommandP = "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)), "/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)), ("/user" <|> "/u") $> ShowActiveUser, - "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP <* " xftp=" <*> onOffP), - "/_start" $> StartChat True True True, - "/_stop" $> APIStopChat, + "/_start" *> (APIStartChat <$> ((A.space *> jsonP) <|> chatCtrlCfgP)), + "/_stop close" $> APIStopChat {closeStore = True}, + "/_stop" $> APIStopChat False, "/_app activate" $> APIActivateChat, "/_app suspend " *> (APISuspendChat <$> A.decimal), "/_resubscribe all" $> ResubscribeAllConnections, @@ -5609,6 +5616,12 @@ chatCommandP = ] where choice = A.choice . map (\p -> p <* A.takeWhile (== ' ') <* A.endOfInput) + chatCtrlCfgP = do + subConns <- (" subscribe=" *> onOffP) <|> pure True + enableExpireCIs <- (" expire=" *> onOffP) <|> pure True + startXFTPWorkers <- (" xftp=" *> onOffP) <|> pure True + openDBWithKey <- optional $ " key=" *> dbKeyP + pure ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey} incognitoP = (A.space *> ("incognito" <|> "i")) $> True <|> pure False incognitoOnOffP = (A.space *> "incognito=" *> onOffP) <|> pure False imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,") diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index f8fa0d152a..55bd31e51f 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -124,7 +124,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D checkFile `with` fs backup `with` fs (export chatDb chatEncrypted >> export agentDb agentEncrypted) - `catchChatError` \e -> (restore `with` fs) >> throwError e + `catchChatError` \e -> tryChatError (restore `with` fs) >> throwError e where action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb backup f = copyFile f (f <> ".bak") diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2c829e4a95..69bc13ddd9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -221,8 +221,8 @@ data ChatCommand | UnmuteUser | APIDeleteUser UserId Bool (Maybe UserPwd) | DeleteUser UserName Bool (Maybe UserPwd) - | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool, startXFTPWorkers :: Bool} - | APIStopChat + | APIStartChat ChatCtrlCfg + | APIStopChat {closeStore :: Bool} | APIActivateChat | APISuspendChat {suspendTimeout :: Int} | ResubscribeAllConnections @@ -620,6 +620,17 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +data ChatCtrlCfg = ChatCtrlCfg + { subConns :: Bool, + enableExpireCIs :: Bool, + startXFTPWorkers :: Bool, + openDBWithKey :: Maybe DBEncryptionKey + } + deriving (Show, Generic, FromJSON) + +defChatCtrlCfg :: ChatCtrlCfg +defChatCtrlCfg = ChatCtrlCfg True True True Nothing + newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 4af161ab41..b094130373 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -35,7 +35,7 @@ runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController runSimplexChat ChatOpts {maintenance} u cc chat | maintenance = wait =<< async (chat u cc) | otherwise = do - a1 <- runReaderT (startChatController True True True) cc + a1 <- runReaderT (startChatController defChatCtrlCfg) cc a2 <- async $ chat u cc waitEither_ a1 a2 diff --git a/stack.yaml b/stack.yaml index 0840970e49..a466178ced 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + commit: fda1284ae4b7c33cae2eb8ed0376a511aecc1d51 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 7da5263253..d947fb63bd 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -171,7 +171,7 @@ startTestChat_ db cfg opts user = do stopTestChat :: TestCC -> IO () stopTestChat TestCC {chatController = cc, chatAsync, termAsync} = do - stopChatController cc + stopChatController cc False uninterruptibleCancel termAsync uninterruptibleCancel chatAsync threadDelay 200000 diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 3db4052226..7dbff89a23 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -953,10 +953,15 @@ testDatabaseEncryption tmp = do alice ##> "/_start" alice <## "chat started" testChatWorking alice bob - alice ##> "/_stop" + alice ##> "/_stop close" alice <## "chat stopped" alice ##> "/db key wrongkey nextkey" alice <## "error encrypting database: wrong passphrase or invalid database file" + alice ##> "/_start key=mykey" + alice <## "chat started" + testChatWorking alice bob + alice ##> "/_stop close" + alice <## "chat stopped" alice ##> "/db key mykey nextkey" alice <## "ok" alice ##> "/_db encryption {\"currentKey\":\"nextkey\",\"newKey\":\"anotherkey\"}" From 7e17ed7b1bd846e8df7fade8fa1c43ebfd5deadc Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 27 Sep 2023 22:34:46 +0800 Subject: [PATCH 007/294] desktop (mac): removing rpaths (#3136) * desktop (mac): removing rpaths * one more lib * added check for dir existence in linking * new line * patching libapp on mac --- .../src/commonMain/cpp/desktop/CMakeLists.txt | 5 ++ .../cpp/desktop/patch-libapp-mac.sh | 8 ++++ scripts/desktop/build-lib-mac.sh | 48 +++++++++++++++---- 3 files changed, 52 insertions(+), 9 deletions(-) create mode 100755 apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt index cfbc1ed320..09ef6fd533 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt @@ -73,6 +73,11 @@ else() target_link_libraries(app-lib rts simplex) endif() +if(APPLE) + add_custom_command(TARGET app-lib POST_BUILD + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/patch-libapp-mac.sh +) +endif() # Trying to copy resulting files into needed directory, but none of these work for some reason. This could allow to diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh b/apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh new file mode 100755 index 0000000000..c44df8f1f0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +lib=libapp-lib.dylib +RPATHS=$(otool -l $lib | grep -E '/Users|/opt/|/usr/local' | cut -d' ' -f11) +for RPATH in $RPATHS; do + install_name_tool -delete_rpath $RPATH $lib +done diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 50bc09df57..8b0386473f 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -e + OS=mac ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" GHC_VERSION=9.6.2 @@ -18,7 +20,7 @@ rm -rf $BUILD_DIR cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" cd $BUILD_DIR/build -mkdir deps 2> /dev/null +mkdir deps 2> /dev/null || true # It's not included by default for some reason. Compiled lib tries to find system one but it's not always available #cp $GHC_LIBS_DIR/libffi.dylib ./deps @@ -54,7 +56,7 @@ function copy_deps() { cp $LIB ./deps if [[ "$NON_FINAL_RPATHS" == *"@loader_path/.."* ]]; then - # Need to point the lib to @loader_path instead + # Need to point the lib to @loader_path instead install_name_tool -add_rpath @loader_path ./deps/`basename $LIB` fi #echo LIB $LIB @@ -79,13 +81,6 @@ copy_deps $LIB cp $(ghc --print-libdir)/$ARCH-osx-ghc-$GHC_VERSION/libHSghc-boot-th-$GHC_VERSION-ghc$GHC_VERSION.dylib deps rm deps/`basename $LIB` -if [ -e deps/libHSdrct-*.$LIB_EXT ]; then - LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) - install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct-*.$LIB_EXT - cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT - chmod 755 deps/libcrypto.1.1.$LIB_EXT -fi - cd - rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ @@ -95,4 +90,39 @@ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ + +cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ + +LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct-*.$LIB_EXT +cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT +chmod 755 deps/libcrypto.1.1.$LIB_EXT +install_name_tool -id "libcrypto.1.1.$LIB_EXT" deps/libcrypto.1.1.$LIB_EXT +install_name_tool -id "libffi.8.$LIB_EXT" deps/libffi.$LIB_EXT + +LIBCRYPTO_PATH=$(otool -l $LIB | grep libcrypto | cut -d' ' -f11) +if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT $LIB +fi + +LIBCRYPTO_PATH=$(otool -l deps/libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSsmplxmq*.$LIB_EXT +fi + +for lib in $(find . -type f -name "*.$LIB_EXT"); do + RPATHS=`otool -l $lib | grep -E "path /Users/|path /usr/local|path /opt/" | cut -d' ' -f11` + for RPATH in $RPATHS; do + install_name_tool -delete_rpath $RPATH $lib + done +done + +LOCAL_DIRS=`for lib in $(find . -type f -name "*.$LIB_EXT"); do otool -l $lib | grep -E "/Users|/opt/|/usr/local" && echo $lib; done` +if [ -n "$LOCAL_DIRS" ]; then + echo These libs still point to local directories: + echo $LOCAL_DIRS + exit 1 +fi + +cd - scripts/desktop/prepare-vlc-mac.sh From cccb3e33fb2388cc7d76f92e515f4808e51a3bf7 Mon Sep 17 00:00:00 2001 From: IC Rainbow Date: Wed, 27 Sep 2023 18:24:38 +0300 Subject: [PATCH 008/294] Plug discovery into remote controller UI --- src/Simplex/Chat.hs | 56 ++++++++++-- src/Simplex/Chat/Controller.hs | 19 ++-- .../Migrations/M20230922_remote_controller.hs | 3 +- src/Simplex/Chat/Remote.hs | 64 +++++++------ src/Simplex/Chat/Remote/Discovery.hs | 89 +++++++------------ src/Simplex/Chat/Remote/Types.hs | 13 +-- src/Simplex/Chat/Store/Remote.hs | 27 +++++- src/Simplex/Chat/View.hs | 2 +- 8 files changed, 167 insertions(+), 106 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index afcd95443e..849bff97fd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -72,6 +72,7 @@ import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.Remote import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -1864,11 +1865,56 @@ processChatCommand = \case DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" - StartRemoteCtrl -> pure $ chatCmdError Nothing "not supported" - ConfirmRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" - RejectRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" - StopRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" - DisposeRemoteCtrl _rc -> pure $ chatCmdError Nothing "not supported" + StartRemoteCtrl -> + chatReadVar remoteCtrlSession >>= \case + Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy + Nothing -> do + uio <- askUnliftIO + accepted <- newEmptyTMVarIO + let getControllers = unliftIO uio $ withStore' $ \db -> + map (\RemoteCtrl{remoteCtrlId, fingerprint} -> (fingerprint, remoteCtrlId)) <$> getRemoteCtrls (DB.conn db) + let started remoteCtrlId = unliftIO uio $ do + withStore' (\db -> getRemoteCtrl (DB.conn db) remoteCtrlId) >>= \case + Nothing -> pure False + Just RemoteCtrl{displayName, accepted=resolution} -> case resolution of + Nothing -> do + -- started/finished wrapper is synchronous, running HTTP server can be delayed here until UI processes the first contact dialogue + toView $ CRRemoteCtrlFirstContact {remoteCtrlId, displayName} + atomically $ takeTMVar accepted + Just known -> atomically $ putTMVar accepted known $> known + let finished remoteCtrlId todo'error = unliftIO uio $ do + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlDisconnected {remoteCtrlId} + let process rc req = unliftIO uio $ processControllerCommand rc req + ctrlAsync <- async . liftIO $ Discovery.runDiscoverer getControllers started finished process + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync, accepted} + pure CRRemoteCtrlStarted + ConfirmRemoteCtrl remoteCtrlId -> do + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {accepted} -> do + withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId True + atomically $ putTMVar accepted True + pure $ CRRemoteCtrlAccepted {remoteCtrlId} + RejectRemoteCtrl remoteCtrlId -> do + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {accepted} -> do + withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId False + atomically $ putTMVar accepted False + pure $ CRRemoteCtrlRejected {remoteCtrlId} + StopRemoteCtrl remoteCtrlId -> + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {ctrlAsync} -> do + cancel ctrlAsync + pure $ CRRemoteCtrlDisconnected {remoteCtrlId} + DisposeRemoteCtrl remoteCtrlId -> + chatReadVar remoteCtrlSession >>= \case + Nothing -> do + withStore' $ \db -> deleteRemoteCtrl (DB.conn db) remoteCtrlId + pure $ CRRemoteCtrlDisposed {remoteCtrlId} + Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c6405990a3..82a1cbcede 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -422,7 +422,7 @@ data ChatCommand | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers | ListRemoteCtrls | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm discovered data and store confirmation - | RejectRemoteCtrl RemoteCtrlId -- ^ Reject discovered data (and blacklist?) + | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data | StopRemoteCtrl RemoteCtrlId -- ^ Stop listening for announcements or terminate an active session | DisposeRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session | QuitChat @@ -602,9 +602,11 @@ data ChatResponse | CRRemoteHostDisposed {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlStarted + | CRRemoteCtrlFirstContact {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlDisconnected {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} @@ -906,7 +908,7 @@ data ChatError | ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity} | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} - | ChatErrorRemoteCtrl {remoteCtrlId :: RemoteCtrlId, remoteControllerError :: RemoteCtrlError} + | ChatErrorRemoteCtrl {remoteControllerError :: RemoteCtrlError} | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} deriving (Show, Exception, Generic) @@ -1036,13 +1038,14 @@ instance ToJSON RemoteHostError where -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteCtrlError - = RCEMissing -- ^ No remote session matches this identifier + = RCEMissing {remoteCtrlId :: RemoteCtrlId} -- ^ No remote session matches this identifier + | RCEInactive -- ^ No session is running | RCEBusy -- ^ A session is already running | RCETimeout -- ^ Remote operation timed out - | RCEDisconnected {reason :: Text} -- ^ A session disconnected by a controller - | RCEConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues - | RCECertificateExpired -- ^ A connection or CA certificate in a chain have bad validity period - | RCECertificateUntrusted -- ^ TLS is unable to validate certificate chain presented for a connection + | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller + | RCEConnectionLost {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected due to transport issues + | RCECertificateExpired {remoteCtrlId :: RemoteCtrlId} -- ^ A connection or CA certificate in a chain have bad validity period + | RCECertificateUntrusted {remoteCtrlId :: RemoteCtrlId} -- ^ TLS is unable to validate certificate chain presented for a connection deriving (Show, Exception, Generic) instance FromJSON RemoteCtrlError where diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs index 070a4e35cb..4890bfc22a 100644 --- a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs +++ b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs @@ -19,7 +19,8 @@ CREATE TABLE remote_hosts ( -- hosts known to a controlling app CREATE TABLE remote_controllers ( -- controllers known to a hosting app remote_controller_id INTEGER PRIMARY KEY, display_name TEXT NOT NULL, - fingerprint BLOB NOT NULL + fingerprint BLOB NOT NULL, + accepted INTEGER ); |] diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 34c6b31a46..8e2bedc979 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -10,9 +10,9 @@ module Simplex.Chat.Remote where import Control.Monad.Except import Control.Monad.IO.Class import qualified Data.Aeson as J +import qualified Data.Binary.Builder as Binary import Data.ByteString.Char8 (ByteString) import qualified Data.Map.Strict as M -import qualified Data.Binary.Builder as Binary import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP2.Client as HTTP2Client import Simplex.Chat.Controller @@ -20,43 +20,44 @@ import Simplex.Chat.Remote.Types import Simplex.Chat.Types import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 -import Simplex.Messaging.Util (bshow) +import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 +import Simplex.Messaging.Util (bshow) import System.Directory (getFileSize) -withRemoteHostSession :: ChatMonad m => RemoteHostId -> (RemoteHostSession -> m a) -> m a +withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a withRemoteHostSession remoteHostId action = do chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteHostId where err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing -closeRemoteHostSession :: ChatMonad m => RemoteHostId -> m () +closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () closeRemoteHostSession rh = withRemoteHostSession rh (liftIO . HTTP2.closeHTTP2Client . ctrlClient) -processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse +processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse processRemoteCommand rhs = \case -- XXX: intercept and filter some commands -- TODO: store missing files on remote host (s, _cmd) -> relayCommand rhs s -relayCommand :: ChatMonad m => RemoteHostSession -> ByteString -> m ChatResponse -relayCommand RemoteHostSession {ctrlClient} s = postBytestring Nothing ctrlClient "/relay" mempty s >>= \case - Left e -> error "TODO: http2chatError" - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - remoteChatResponse <- - if iTax then - case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> error "TODO: json2chatError" e - Right (raw :: J.Value) -> case J.fromJSON (sum2tagged raw) of - J.Error e -> error "TODO: json2chatError" e - J.Success cr -> pure cr - else - case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> error "TODO: json2chatError" e - Right cr -> pure cr - case remoteChatResponse of - -- TODO: intercept file responses and fetch files when needed - -- XXX: is that even possible, to have a file response to a command? - _ -> pure remoteChatResponse +relayCommand :: (ChatMonad m) => RemoteHostSession -> ByteString -> m ChatResponse +relayCommand RemoteHostSession {ctrlClient} s = + postBytestring Nothing ctrlClient "/relay" mempty s >>= \case + Left e -> error "TODO: http2chatError" + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do + remoteChatResponse <- + if iTax + then case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> error "TODO: json2chatError" e + Right (raw :: J.Value) -> case J.fromJSON (sum2tagged raw) of + J.Error e -> error "TODO: json2chatError" e + J.Success cr -> pure cr + else case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> error "TODO: json2chatError" e + Right cr -> pure cr + case remoteChatResponse of + -- TODO: intercept file responses and fetch files when needed + -- XXX: is that even possible, to have a file response to a command? + _ -> pure remoteChatResponse where iTax = True -- TODO: get from RemoteHost -- XXX: extract to http2 transport @@ -64,11 +65,11 @@ relayCommand RemoteHostSession {ctrlClient} s = postBytestring Nothing ctrlClien where req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) -storeRemoteFile :: ChatMonad m => RemoteHostSession -> FilePath -> m ChatResponse +storeRemoteFile :: (ChatMonad m) => RemoteHostSession -> FilePath -> m ChatResponse storeRemoteFile RemoteHostSession {ctrlClient} localFile = do postFile Nothing ctrlClient "/store" mempty localFile >>= \case Left e -> error "TODO: http2chatError" - Right HTTP2.HTTP2Response { response } -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of + Right HTTP2.HTTP2Response {response} -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of Just 200 -> pure $ CRCmdOk Nothing unexpected -> error "TODO: http2chatError" where @@ -78,7 +79,7 @@ storeRemoteFile RemoteHostSession {ctrlClient} localFile = do where req size = HTTP2Client.requestFile "POST" path hs (HTTP2Client.FileSpec file 0 size) -fetchRemoteFile :: ChatMonad m => RemoteHostSession -> FileTransferId -> m ChatResponse +fetchRemoteFile :: (ChatMonad m) => RemoteHostSession -> FileTransferId -> m ChatResponse fetchRemoteFile RemoteHostSession {ctrlClient, storePath} remoteFileId = do liftIO (HTTP2.sendRequest ctrlClient req Nothing) >>= \case Left e -> error "TODO: http2chatError" @@ -93,3 +94,12 @@ sum2tagged :: J.Value -> J.Value sum2tagged = \case J.Object todo'convert -> J.Object todo'convert skip -> skip + +-- withRemoteCtrlSession :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrlSession -> m a) -> m a +-- withRemoteCtrlSession remoteCtrlId action = do +-- chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteCtrlId +-- where +-- err = throwError $ ChatErrorRemoteCtrl (Just remoteCtrlId) RCMissing + +processControllerCommand :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () +processControllerCommand rc req = error "TODO: processControllerCommand" diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index ace1ced313..cb668677f5 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -10,19 +10,13 @@ module Simplex.Chat.Remote.Discovery where import Control.Monad -import Data.ByteString.Builder (Builder, intDec) import Data.Default (def) import Data.String (IsString) -import Data.Text (Text) -import Data.Text.Encoding (encodeUtf8) import Debug.Trace -import qualified Network.HTTP.Types as HTTP -import qualified Network.HTTP2.Server as HTTP2 import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP -import Simplex.Chat.Controller (ChatMonad) -import Simplex.Chat.Types () +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Transport (supportedParameters) import qualified Simplex.Messaging.Transport as Transport @@ -34,7 +28,7 @@ import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTran import UnliftIO import UnliftIO.Concurrent -runAnnouncer :: (StrEncoding invite, ChatMonad m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) +runAnnouncer :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) runAnnouncer finished invite credentials = do started <- newEmptyTMVarIO aPid <- async $ announcer started (strEncode invite) @@ -76,57 +70,38 @@ broadcastAddrV4 = "255.255.255.255" partyPort :: (IsString a) => a partyPort = "5226" -- XXX: should be `0` or something, to get a random port and announce it -runDiscoverer :: (ChatMonad m) => Text -> m () -runDiscoverer oobData = - case strDecode (encodeUtf8 oobData) of - Left err -> traceM $ "oobData decode error: " <> err - Right expected -> liftIO $ do - traceM $ "runDiscoverer: locating " <> show oobData - sock <- UDP.serverSocket (broadcastAddrV4, read partyPort) - N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 - traceM $ "runDiscoverer: " <> show sock - go sock expected +runDiscoverer :: IO [(C.KeyHash, ctx)] -> (ctx -> IO Bool) -> (ctx -> Maybe SomeException -> IO ()) -> (ctx -> HTTP2Request -> IO ()) -> IO () +runDiscoverer getFingerprints started finished processRequest = do + sock <- UDP.serverSocket (broadcastAddrV4, read partyPort) + N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 + traceM $ "runDiscoverer: " <> show sock + go sock where - go sock expected = do + go sock = do (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock - traceShowM (invite, source) - let expect hash = hash `elem` [expected] -- XXX: can be a callback to fetch actual invite list just in time case strDecode invite of Left err -> do traceM $ "Inivite decode error: " <> err - go sock expected - Right inviteHash | not (expect inviteHash) -> do - traceM $ "Skipping unexpected invite " <> show (strEncode inviteHash) - go sock expected - Right _expected -> do - host <- case source of - N.SockAddrInet _port addr -> do - pure $ THIPv4 (N.hostAddressToTuple addr) - unexpected -> - -- TODO: actually, Apple mandates IPv6 support - fail $ "Discoverer: expected an IPv4 party, got " <> show unexpected - traceM $ "Discoverer: go connect " <> show host - runTransportClient defaultTransportClientConfig Nothing host partyPort (Just expected) $ \tls -> do - traceM "2PTTH server starting" - run tls - traceM "2PTTH server finished" - - run tls = runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do - reqBody <- getHTTP2Body r 16384 - processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} - - processRequest req = do - traceM $ "Got request: " <> show (request req) - -- TODO: sendResponse req . HTTP2.promiseResponse $ HTTP2.pushPromise path response weight - sendResponse req $ HTTP2.responseStreaming HTTP.ok200 sseHeaders sseExample - - sseHeaders = [(HTTP.hContentType, "text/event-stream")] - - sseExample :: (Builder -> IO ()) -> IO () -> IO () - sseExample write flush = forM_ [1 .. 10] $ \i -> do - let payload = "[" <> intDec i <> ", \"blah\"]" - write "event: message\n" -- XXX: SSE header line - write $ "data: " <> payload <> "\n" -- XXX: SSE payload line - write "\n" -- XXX: SSE delimiter - flush - threadDelay 1000000 + go sock + Right inviteHash -> do + expected <- getFingerprints + case lookup inviteHash expected of + Nothing -> do + traceM $ "Unexpected invite: " <> show (invite, source) + go sock + Just ctx -> do + host <- case source of + N.SockAddrInet _port addr -> do + pure $ THIPv4 (N.hostAddressToTuple addr) + unexpected -> + -- TODO: actually, Apple mandates IPv6 support + fail $ "Discoverer: expected an IPv4 party, got " <> show unexpected + runTransportClient defaultTransportClientConfig Nothing host partyPort (Just inviteHash) $ \tls -> do + accepted <- started ctx + if not accepted + then go sock -- Ignore rejected invites and wait for another + else do + res <- try $ runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + reqBody <- getHTTP2Body r 16384 + processRequest ctx HTTP2Request {sessionId, request = r, reqBody, sendResponse} + finished ctx $ either Just (\() -> Nothing) res diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 9f28eab551..53f73c3389 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -8,6 +8,7 @@ import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import UnliftIO.STM type RemoteHostId = Int64 @@ -22,12 +23,13 @@ data RemoteHost = RemoteHost caKey :: C.Key } -type RemoteCtrlId = Int +type RemoteCtrlId = Int64 data RemoteCtrl = RemoteCtrl { remoteCtrlId :: RemoteCtrlId, displayName :: Text, - fingerprint :: Text + fingerprint :: C.KeyHash, + accepted :: Maybe Bool } data RemoteHostSession = RemoteHostSession @@ -36,9 +38,8 @@ data RemoteHostSession = RemoteHostSession ctrlClient :: HTTP2Client } --- | Host-side dual to RemoteHostSession, on-methods represent HTTP API. data RemoteCtrlSession = RemoteCtrlSession - { -- | process to communicate with the remote controller - ctrlAsync :: Async () - -- server :: HTTP2Server + { -- | Server side of transport to process remote commands and forward notifications + ctrlAsync :: Async (), + accepted :: TMVar Bool } diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 12fcb6c081..f185000d88 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -7,7 +8,7 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import qualified Database.SQLite.Simple as DB -import Simplex.Chat.Remote.Types (RemoteHostId, RemoteHost (..)) +import Simplex.Chat.Remote.Types (RemoteCtrl (..), RemoteCtrlId, RemoteHost (..), RemoteHostId) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) import qualified Simplex.Messaging.Crypto as C @@ -26,3 +27,27 @@ remoteHostQuery = "SELECT remote_host_id, display_name, store_path, ca_cert, ca_ toRemoteHost :: (Int64, Text, FilePath, ByteString, C.Key) -> RemoteHost toRemoteHost (remoteHostId, displayName, storePath, caCert, caKey) = RemoteHost {remoteHostId, displayName, storePath, caCert, caKey} + +getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] +getRemoteCtrls db = + map toRemoteCtrl <$> DB.query_ db remoteCtrlQuery + +getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO (Maybe RemoteCtrl) +getRemoteCtrl db remoteCtrlId = + maybeFirstRow toRemoteCtrl $ + DB.query db (remoteCtrlQuery <> "WHERE remote_controller_id = ?") (DB.Only remoteCtrlId) + +remoteCtrlQuery :: DB.Query +remoteCtrlQuery = "SELECT remote_controller_id, display_name, fingerprint, accepted FROM remote_controllers" + +toRemoteCtrl :: (Int64, Text, C.KeyHash, Maybe Bool) -> RemoteCtrl +toRemoteCtrl (remoteCtrlId, displayName, fingerprint, accepted) = + RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} + +markRemoteCtrlResolution :: DB.Connection -> RemoteCtrlId -> Bool -> IO () +markRemoteCtrlResolution db remoteCtrlId accepted = + DB.execute db "UPDATE remote_controllers SET accepted = ? WHERE remote_controller_id = ?" (accepted, remoteCtrlId) + +deleteRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO () +deleteRemoteCtrl db remoteCtrlId = + DB.execute db "DELETE FROM remote_controllers WHERE remote_controller_id = ?" (DB.Only remoteCtrlId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1f110fc951..1761b384ac 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1678,7 +1678,7 @@ viewChatError logLevel = \case Nothing -> "" cId :: Connection -> StyledString cId conn = sShow conn.connId - ChatErrorRemoteCtrl remoteCtrlId todo'rc -> [sShow remoteCtrlId, sShow todo'rc] + ChatErrorRemoteCtrl todo'rc -> [sShow todo'rc] ChatErrorRemoteHost remoteHostId todo'rh -> [sShow remoteHostId, sShow todo'rh] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] From c64d1e83618eb66d92ee6008db4df41939631033 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 27 Sep 2023 19:36:13 +0400 Subject: [PATCH 009/294] core: notify contact about contact deletion (#3131) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 35 +++++++++++++------ src/Simplex/Chat/Controller.hs | 4 ++- src/Simplex/Chat/Messages/CIContent.hs | 34 ++++++++++++++++++ .../Migrations/M20230926_contact_status.hs | 18 ++++++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Protocol.hs | 7 ++++ src/Simplex/Chat/Store/Connections.hs | 8 ++--- src/Simplex/Chat/Store/Direct.hs | 24 ++++++++++--- src/Simplex/Chat/Store/Groups.hs | 25 ++++++------- src/Simplex/Chat/Store/Messages.hs | 2 +- src/Simplex/Chat/Store/Migrations.hs | 4 ++- src/Simplex/Chat/Store/Shared.hs | 10 +++--- src/Simplex/Chat/Types.hs | 28 ++++++++++++++- src/Simplex/Chat/View.hs | 2 ++ tests/ChatTests/Direct.hs | 6 ++-- tests/ChatTests/Files.hs | 8 ++++- tests/ChatTests/Groups.hs | 14 +++++--- tests/ChatTests/Profiles.hs | 5 +++ 19 files changed, 189 insertions(+), 47 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230926_contact_status.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 338346b655..dc3a23adc0 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -113,6 +113,7 @@ library Simplex.Chat.Migrations.M20230903_connections_to_subscribe Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes + Simplex.Chat.Migrations.M20230926_contact_status Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 1626fe8fda..3530fa82cb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -904,14 +904,15 @@ processChatCommand = \case liftIO $ updateGroupUnreadChat db user groupInfo unreadChat ok user _ -> pure $ chatCmdError (Just user) "not supported" - APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of + APIDeleteChat (ChatRef cType chatId) notify -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) withChatLock "deleteChat direct" . procCmd $ do - fileAgentConnIds <- concat <$> forM filesInfo (deleteFile user) - deleteAgentConnectionsAsync user $ fileAgentConnIds <> contactConnIds + deleteFilesAndConns user filesInfo + when (contactActive ct && notify) . void $ sendDirectContactMessage ct XDirectDel + contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) + deleteAgentConnectionsAsync user contactConnIds -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct @@ -1334,7 +1335,7 @@ processChatCommand = \case ConnectSimplex incognito -> withUser $ \user -> -- [incognito] generate profile to send connectViaContact user incognito adminContactReq - DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect + DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> CRContactsList user <$> withStore' (`getUserContacts` user) @@ -1429,7 +1430,7 @@ processChatCommand = \case processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore' (`getUserContacts` user) - let cts = filter (\ct -> isReady ct && directOrUsed ct) contacts + let cts = filter (\ct -> isReady ct && contactActive ct && directOrUsed ct) contacts ChatConfig {logLevel} <- asks config withChatLock "sendMessageBroadcast" . procCmd $ do (successes, failures) <- foldM (sendAndCount user logLevel) (0, 0) cts @@ -1597,7 +1598,7 @@ processChatCommand = \case processChatCommand $ APILeaveGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) + processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) True ClearGroup gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIClearChat (ChatRef CTGroup groupId) @@ -1979,7 +1980,7 @@ processChatCommand = \case -- read contacts before user update to correctly merge preferences -- [incognito] filter out contacts with whom user has incognito connections contacts <- - filter (\ct -> isReady ct && not (contactConnIncognito ct)) + filter (\ct -> isReady ct && contactActive ct && not (contactConnIncognito ct)) <$> withStore' (`getUserContacts` user) user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') @@ -3041,6 +3042,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId msgMeta XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName msgMeta XInfo p -> xInfo ct' p + XDirectDel -> xDirectDel ct' msg msgMeta XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta XInfoProbe probe -> xInfoProbe (CGMContact ct') probe XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash @@ -4245,6 +4247,18 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xInfo :: Contact -> Profile -> m () xInfo c p' = void $ processContactProfileUpdate c p' True + xDirectDel :: Contact -> RcvMessage -> MsgMeta -> m () + xDirectDel c msg msgMeta = do + checkIntegrityCreateItem (CDDirectRcv c) msgMeta + ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted + contactConns <- withStore $ \db -> getContactConnections db userId ct' + deleteAgentConnectionsAsync user $ map aConnId contactConns + forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + let ct'' = ct' {activeConn = (contactConn ct') {connStatus = ConnDeleted}} :: Contact + ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) + toView $ CRContactDeletedByContact user ct'' + processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact processContactProfileUpdate c@Contact {profile = p} p' createItems | fromLocalProfile p /= p' = do @@ -4928,8 +4942,9 @@ deleteOrUpdateMemberRecord user@User {userId} member = Nothing -> deleteGroupMember db user member sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64) -sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent +sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}, contactStatus} chatMsgEvent | connStatus /= ConnReady && connStatus /= ConnSndReady = throwChatError $ CEContactNotReady ct + | contactStatus /= CSActive = throwChatError $ CEContactNotActive ct | connDisabled conn = throwChatError $ CEContactDisabled ct | otherwise = sendDirectMessage conn chatMsgEvent (ConnectionId connId) @@ -5418,7 +5433,7 @@ chatCommandP = "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))), "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), - "/_delete " *> (APIDeleteChat <$> chatRefP), + "/_delete " *> (APIDeleteChat <$> chatRefP <*> (A.space *> "notify=" *> onOffP <|> pure True)), "/_clear chat " *> (APIClearChat <$> chatRefP), "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 69bc13ddd9..2931a874ee 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -248,7 +248,7 @@ data ChatCommand | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) | APIChatUnread ChatRef Bool - | APIDeleteChat ChatRef + | APIDeleteChat ChatRef Bool -- `notify` flag is only applied to direct chats | APIClearChat ChatRef | APIAcceptContact IncognitoEnabled Int64 | APIRejectContact Int64 @@ -491,6 +491,7 @@ data ChatResponse | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact} | CRContactDeleted {user :: User, contact :: Contact} + | CRContactDeletedByContact {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} | CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact} | CRUserContactLinkDeleted {user :: User} @@ -898,6 +899,7 @@ data ChatErrorType | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} | CEContactNotReady {contact :: Contact} + | CEContactNotActive {contact :: Contact} | CEContactDisabled {contact :: Contact} | CEConnectionDisabled {connection :: Connection} | CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole} diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index df22c2684c..9abc8e4644 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -132,6 +132,7 @@ data CIContent (d :: MsgDirection) where CIRcvDecryptionError :: MsgDecryptError -> Word32 -> CIContent 'MDRcv CIRcvGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDRcv CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd + CIRcvDirectEvent :: RcvDirectEvent -> CIContent 'MDRcv CIRcvGroupEvent :: RcvGroupEvent -> CIContent 'MDRcv CISndGroupEvent :: SndGroupEvent -> CIContent 'MDSnd CIRcvConnEvent :: RcvConnEvent -> CIContent 'MDRcv @@ -179,6 +180,7 @@ ciRequiresAttention content = case msgDirection @d of CIRcvIntegrityError _ -> True CIRcvDecryptionError {} -> True CIRcvGroupInvitation {} -> True + CIRcvDirectEvent _ -> False CIRcvGroupEvent rge -> case rge of RGEMemberAdded {} -> False RGEMemberConnected -> False @@ -300,6 +302,27 @@ instance ToJSON DBSndConnEvent where toJSON (SCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SCE") v toEncoding (SCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SCE") v +data RcvDirectEvent = + -- RDEProfileChanged {...} + RDEContactDeleted + deriving (Show, Generic) + +instance FromJSON RcvDirectEvent where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RDE" + +instance ToJSON RcvDirectEvent where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RDE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RDE" + +newtype DBRcvDirectEvent = RDE RcvDirectEvent + +instance FromJSON DBRcvDirectEvent where + parseJSON v = RDE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RDE") v + +instance ToJSON DBRcvDirectEvent where + toJSON (RDE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RDE") v + toEncoding (RDE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RDE") v + newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where @@ -348,6 +371,7 @@ ciContentToText = \case CIRcvDecryptionError err n -> msgDecryptErrorText err n CIRcvGroupInvitation groupInvitation memberRole -> "received " <> ciGroupInvitationToText groupInvitation memberRole CISndGroupInvitation groupInvitation memberRole -> "sent " <> ciGroupInvitationToText groupInvitation memberRole + CIRcvDirectEvent event -> rcvDirectEventToText event CIRcvGroupEvent event -> rcvGroupEventToText event CISndGroupEvent event -> sndGroupEventToText event CIRcvConnEvent event -> rcvConnEventToText event @@ -368,6 +392,10 @@ ciGroupInvitationToText :: CIGroupInvitation -> GroupMemberRole -> Text ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayName, fullName}} role = "invitation to join group " <> displayName <> optionalFullName displayName fullName <> " as " <> (decodeLatin1 . strEncode $ role) +rcvDirectEventToText :: RcvDirectEvent -> Text +rcvDirectEventToText = \case + RDEContactDeleted -> "contact deleted" + rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText = \case RGEMemberAdded _ p -> "added " <> profileToText p @@ -486,6 +514,7 @@ data JSONCIContent | JCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} | JCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | JCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} + | JCIRcvDirectEvent {rcvDirectEvent :: RcvDirectEvent} | JCIRcvGroupEvent {rcvGroupEvent :: RcvGroupEvent} | JCISndGroupEvent {sndGroupEvent :: SndGroupEvent} | JCIRcvConnEvent {rcvConnEvent :: RcvConnEvent} @@ -522,6 +551,7 @@ jsonCIContent = \case CIRcvDecryptionError err n -> JCIRcvDecryptionError err n CIRcvGroupInvitation groupInvitation memberRole -> JCIRcvGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> JCISndGroupInvitation {groupInvitation, memberRole} + CIRcvDirectEvent rcvDirectEvent -> JCIRcvDirectEvent {rcvDirectEvent} CIRcvGroupEvent rcvGroupEvent -> JCIRcvGroupEvent {rcvGroupEvent} CISndGroupEvent sndGroupEvent -> JCISndGroupEvent {sndGroupEvent} CIRcvConnEvent rcvConnEvent -> JCIRcvConnEvent {rcvConnEvent} @@ -550,6 +580,7 @@ aciContentJSON = \case JCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n JCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole JCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole + JCIRcvDirectEvent {rcvDirectEvent} -> ACIContent SMDRcv $ CIRcvDirectEvent rcvDirectEvent JCIRcvGroupEvent {rcvGroupEvent} -> ACIContent SMDRcv $ CIRcvGroupEvent rcvGroupEvent JCISndGroupEvent {sndGroupEvent} -> ACIContent SMDSnd $ CISndGroupEvent sndGroupEvent JCIRcvConnEvent {rcvConnEvent} -> ACIContent SMDRcv $ CIRcvConnEvent rcvConnEvent @@ -579,6 +610,7 @@ data DBJSONCIContent | DBJCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} | DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} + | DBJCIRcvDirectEvent {rcvDirectEvent :: DBRcvDirectEvent} | DBJCIRcvGroupEvent {rcvGroupEvent :: DBRcvGroupEvent} | DBJCISndGroupEvent {sndGroupEvent :: DBSndGroupEvent} | DBJCIRcvConnEvent {rcvConnEvent :: DBRcvConnEvent} @@ -615,6 +647,7 @@ dbJsonCIContent = \case CIRcvDecryptionError err n -> DBJCIRcvDecryptionError err n CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole} + CIRcvDirectEvent rde -> DBJCIRcvDirectEvent $ RDE rde CIRcvGroupEvent rge -> DBJCIRcvGroupEvent $ RGE rge CISndGroupEvent sge -> DBJCISndGroupEvent $ SGE sge CIRcvConnEvent rce -> DBJCIRcvConnEvent $ RCE rce @@ -643,6 +676,7 @@ aciContentDBJSON = \case DBJCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole + DBJCIRcvDirectEvent (RDE rde) -> ACIContent SMDRcv $ CIRcvDirectEvent rde DBJCIRcvGroupEvent (RGE rge) -> ACIContent SMDRcv $ CIRcvGroupEvent rge DBJCISndGroupEvent (SGE sge) -> ACIContent SMDSnd $ CISndGroupEvent sge DBJCIRcvConnEvent (RCE rce) -> ACIContent SMDRcv $ CIRcvConnEvent rce diff --git a/src/Simplex/Chat/Migrations/M20230926_contact_status.hs b/src/Simplex/Chat/Migrations/M20230926_contact_status.hs new file mode 100644 index 0000000000..b6c5dd9557 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230926_contact_status.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230926_contact_status where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230926_contact_status :: Query +m20230926_contact_status = + [sql| +ALTER TABLE contacts ADD COLUMN contact_status TEXT NOT NULL DEFAULT 'active'; +|] + +down_m20230926_contact_status :: Query +down_m20230926_contact_status = + [sql| +ALTER TABLE contacts DROP COLUMN contact_status; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 141247e590..65ceb7d19b 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -71,6 +71,7 @@ CREATE TABLE contacts( contact_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL, contact_grp_inv_sent INTEGER NOT NULL DEFAULT 0, + contact_status TEXT NOT NULL DEFAULT 'active', FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 6e725e6c26..bbdddf8ce0 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -215,6 +215,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XInfo :: Profile -> ChatMsgEvent 'Json XContact :: Profile -> Maybe XContactId -> ChatMsgEvent 'Json + XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json @@ -550,6 +551,7 @@ data CMEventTag (e :: MsgEncoding) where XFileCancel_ :: CMEventTag 'Json XInfo_ :: CMEventTag 'Json XContact_ :: CMEventTag 'Json + XDirectDel_ :: CMEventTag 'Json XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json @@ -596,6 +598,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XFileCancel_ -> "x.file.cancel" XInfo_ -> "x.info" XContact_ -> "x.contact" + XDirectDel_ -> "x.direct.del" XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" XGrpMemNew_ -> "x.grp.mem.new" @@ -643,6 +646,7 @@ instance StrEncoding ACMEventTag where "x.file.cancel" -> XFileCancel_ "x.info" -> XInfo_ "x.contact" -> XContact_ + "x.direct.del" -> XDirectDel_ "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ "x.grp.mem.new" -> XGrpMemNew_ @@ -686,6 +690,7 @@ toCMEventTag msg = case msg of XFileCancel _ -> XFileCancel_ XInfo _ -> XInfo_ XContact _ _ -> XContact_ + XDirectDel -> XDirectDel_ XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ XGrpMemNew _ -> XGrpMemNew_ @@ -782,6 +787,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XFileCancel_ -> XFileCancel <$> p "msgId" XInfo_ -> XInfo <$> p "profile" XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" + XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" @@ -839,6 +845,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] + XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 7da0d1ca85..93f3349ca2 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -71,19 +71,19 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do db [sql| SELECT - c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.via_group, c.contact_used, c.enable_ntfs, c.send_rcpts, c.favorite, + c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.via_group, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact - toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = + toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact + toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = ExceptT $ do diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7e8cee0e74..886c735053 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -42,6 +42,7 @@ module Simplex.Chat.Store.Direct deletePCCIncognitoProfile, updateContactUsed, updateContactUnreadChat, + updateContactStatus, updateGroupUnreadChat, setConnectionVerified, incConnectionAuthErrCounter, @@ -147,7 +148,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, @@ -206,7 +207,7 @@ createDirectContact db user@User {userId} activeConn@Connection {connId, localAl let profile = toLocalProfile profileId p localAlias userPreferences = emptyChatPrefs mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} + pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO () deleteContactConnectionsAndFiles db userId Contact {contactId} = do @@ -387,6 +388,19 @@ updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do updatedAt <- getCurrentTime DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (unreadChat, updatedAt, userId, contactId) +updateContactStatus :: DB.Connection -> User -> Contact -> ContactStatus -> IO Contact +updateContactStatus db User {userId} ct@Contact {contactId} contactStatus = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE contacts + SET contact_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + |] + (contactStatus, currentTs, userId, contactId) + pure ct {contactStatus} + updateGroupUnreadChat :: DB.Connection -> User -> GroupInfo -> Bool -> IO () updateGroupUnreadChat db User {userId} GroupInfo {groupId} unreadChat = do updatedAt <- getCurrentTime @@ -491,7 +505,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, @@ -637,7 +651,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} contactId <- insertedRowId db activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} + pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 getContactIdByName db User {userId} cName = @@ -655,7 +669,7 @@ getContact_ db user@User {userId} contactId deleted = [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index d6aa3a5b92..e72ca8e8ca 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -700,7 +700,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, @@ -1044,7 +1044,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = db [sql| SELECT - ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, @@ -1062,13 +1062,13 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = |] (userId, groupMemberId) where - toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact - toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = + toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact + toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} activeConn = toConnection connRow mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} @@ -1160,8 +1160,8 @@ getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact] getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of - Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, displayName, fullName, img) - Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, displayName, fullName) + Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, CSActive, displayName, fullName, img) + Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, CSActive, displayName, fullName) rights <$> mapM (runExceptT . getContact db user) contactIds where -- this query is different from one in getMatchingMemberContacts @@ -1172,7 +1172,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? AND ct.contact_id != ? - AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND p.display_name = ? AND p.full_name = ? |] @@ -1521,7 +1521,7 @@ createMemberContact connId <- insertedRowId db let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} getMemberContact :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db user contactId = do @@ -1558,7 +1558,7 @@ createMemberContactInvited contactId <- createContactUpdateMember currentTs userPreferences ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} + mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} m' = m {memberContactId = Just contactId} pure (mCt', m') where @@ -1586,8 +1586,9 @@ updateMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> Gr updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = oldContactConn} subMode = do updateConnectionStatus db oldContactConn ConnDeleted activeConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode - ct' <- resetMemberContactFields db ct - pure (ct' :: Contact) {activeConn} + ct' <- updateContactStatus db user ct CSActive + ct'' <- resetMemberContactFields db ct' + pure (ct'' :: Contact) {activeConn} resetMemberContactFields :: DB.Connection -> Contact -> IO Contact resetMemberContactFields db ct@Contact {contactId} = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index c08e6b11d3..458944b6e5 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -478,7 +478,7 @@ getDirectChatPreviews_ db user@User {userId} = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index d8bab817e3..2f5bfec9ea 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -81,6 +81,7 @@ import Simplex.Chat.Migrations.M20230829_connections_chat_vrange import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes +import Simplex.Chat.Migrations.M20230926_contact_status import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -161,7 +162,8 @@ schemaMigrations = ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange), ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), - ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes) + ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), + ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index e979c90067..4dc4f6e82d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -241,24 +241,24 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] [":user_id" := userId, ":profile_id" := profileId] -type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) +type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) toContact :: User -> ContactRow :. ConnectionRow -> Contact -toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = +toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toConnection connRow chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact -toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = +toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} in case toMaybeConnection connRow of Just activeConn -> let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} _ -> Left $ SEContactNotReady localDisplayName getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 93964316cc..43265671b6 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -169,6 +169,7 @@ data Contact = Contact activeConn :: Connection, viaGroup :: Maybe Int64, contactUsed :: Bool, + contactStatus :: ContactStatus, chatSettings :: ChatSettings, userPreferences :: Preferences, mergedPreferences :: ContactUserPreferences, @@ -185,7 +186,7 @@ instance ToJSON Contact where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} contactConn :: Contact -> Connection -contactConn Contact{activeConn} = activeConn +contactConn Contact {activeConn} = activeConn contactConnId :: Contact -> ConnId contactConnId = aConnId . contactConn @@ -205,9 +206,34 @@ directOrUsed ct@Contact {contactUsed} = anyDirectOrUsed :: Contact -> Bool anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed +contactActive :: Contact -> Bool +contactActive Contact {contactStatus} = contactStatus == CSActive + contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode activeConn +data ContactStatus + = CSActive + | CSDeleted -- contact deleted by contact + deriving (Eq, Show, Ord) + +instance FromField ContactStatus where fromField = fromTextField_ textDecode + +instance ToField ContactStatus where toField = toField . textEncode + +instance ToJSON ContactStatus where + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode + +instance TextEncoding ContactStatus where + textDecode = \case + "active" -> Just CSActive + "deleted" -> Just CSDeleted + _ -> Nothing + textEncode = \case + CSActive -> "active" + CSDeleted -> "deleted" + data ContactRef = ContactRef { contactId :: ContactId, connId :: Int64, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5db0c317e8..01bdfba95a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -151,6 +151,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] + CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRAcceptingContactRequest u c -> ttyUser u [ttyFullContact c <> ": accepting contact request..."] CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] @@ -1567,6 +1568,7 @@ viewChatError logLevel = \case ] CEContactNotFound cName m_ -> viewContactNotFound cName m_ CEContactNotReady c -> [ttyContact' c <> ": not ready"] + CEContactNotActive c -> [ttyContact' c <> ": not active"] CEContactDisabled Contact {localDisplayName = c} -> [ttyContact c <> ": disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)] CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning] CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 7dbff89a23..36e74e11f7 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -156,11 +156,12 @@ testAddContact = versionTestMatrix2 runTestAddContact -- test deleting contact alice ##> "/d bob_1" alice <## "bob_1: contact is deleted" + bob <## "alice_1 (Alice) deleted contact with you" alice ##> "@bob_1 hey" alice <## "no contact bob_1" alice @@@ [("@bob", "how are you?")] alice `hasContactProfiles` ["alice", "bob"] - bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")] + bob @@@ [("@alice_1", "contact deleted"), ("@alice", "how are you?")] bob `hasContactProfiles` ["alice", "alice", "bob"] -- test clearing chat alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") @@ -202,6 +203,7 @@ testDeleteContactDeletesProfile = -- alice deletes contact, profile is deleted alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice ##> "/_contacts 1" (alice bob threadDelay 500000 - bob ##> "/d alice" + bob ##> "/_delete @2 notify=off" bob <## "alice: contact is deleted" forM_ [1 .. authErrDisableCount] $ \_ -> sendAuth alice alice <## "[bob] connection is disabled, to enable: /enable bob, to delete: /d bob" diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index f84d4dcb40..50f86d8e09 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -575,6 +575,7 @@ testSendImage = -- deleting contact without files folder set should not remove file bob ##> "/d alice" bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists `shouldBe` True @@ -637,6 +638,7 @@ testFilesFoldersSendImage = checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO () testFilesFoldersImageSndDelete = @@ -660,6 +662,7 @@ testFilesFoldersImageSndDelete = checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" bob ##> "/fs 1" bob <##. "receiving file 1 (test_1MB.pdf) progress" -- deleting contact should remove cancelled file @@ -689,7 +692,10 @@ testFilesFoldersImageRcvDelete = checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" - alice <## "bob cancelled receiving file 1 (test.jpg)" + alice + <### [ "bob (Bob) deleted contact with you", + "bob cancelled receiving file 1 (test.jpg)" + ] alice ##> "/fs 1" alice <## "sending file 1 (test.jpg) cancelled: bob" alice <## "file transfer cancelled" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bf740a960f..4280810ca6 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -220,6 +220,7 @@ testGroupShared alice bob cath checkMessages = do -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice `send` "@bob hey" alice <### [ "@bob hey", @@ -234,7 +235,7 @@ testGroupShared alice bob cath checkMessages = do alice <# "#team bob> received" when checkMessages $ do alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] - bob @@@ [("@alice", "received invitation to join group team as admin"), ("@cath", "hey"), ("#team", "received")] + bob @@@ [("@alice", "contact deleted"), ("@cath", "hey"), ("#team", "received")] -- test clearing chat threadDelay 1000000 alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") @@ -629,6 +630,7 @@ testGroupDeleteInvitedContact = threadDelay 500000 alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" bob ##> "/j team" concurrently_ (alice <## "#team: bob joined the group") @@ -700,10 +702,11 @@ testDeleteGroupMemberProfileKept = -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice ##> "@bob hey" alice <## "no contact bob, use @#club bob " - bob #> "@alice hey" - bob <## "[alice, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" + bob ##> "@alice hey" + bob <## "alice: not ready" (alice "/d #team" @@ -2785,6 +2788,8 @@ testMemberContactMessage = -- alice and bob delete contacts, connect alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + bob ##> "/d alice" bob <## "alice: contact is deleted" @@ -2893,6 +2898,7 @@ testMemberContactInvitedConnectionReplaced tmp = do alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice ##> "@#team bob hi" alice @@ -2910,7 +2916,7 @@ testMemberContactInvitedConnectionReplaced tmp = do (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "hi"), (0, "security code changed")] <> chatFeatures) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] <> chatFeatures) withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 1a2b74f76e..44af70a65e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -558,6 +558,7 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi -- alice deletes contact, incognito profile is deleted alice ##> ("/d " <> bobIncognito) alice <## (bobIncognito <> ": contact is deleted") + bob <## (aliceIncognito <> " deleted contact with you") alice ##> "/contacts" alice <## "cath (Catherine)" alice `hasContactProfiles` ["alice", "cath"] @@ -601,6 +602,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ -- delete contact, incognito profile is deleted bob ##> "/d alice" bob <## "alice: contact is deleted" + alice <## (bobIncognito <> " deleted contact with you") bob ##> "/contacts" (bob "/d bob" alice <## "bob: contact is deleted" + bob <## (aliceIncognitoBob <> " deleted contact with you") alice ##> "/contacts" (alice "/d alice" bob <## "alice: contact is deleted" + alice <## (bobIncognito <> " deleted contact with you") bob ##> "/contacts" (bob "/d alice" bob <## "alice: contact is deleted" + alice <## (bobIncognito <> " deleted contact with you") bob ##> "/contacts" (bob Date: Wed, 27 Sep 2023 20:07:32 +0400 Subject: [PATCH 010/294] ios: notify contact about contact deletion (#3135) --- apps/ios/Shared/Model/SimpleXAPI.swift | 6 +++ apps/ios/Shared/Views/Chat/ChatInfoView.swift | 7 ++- apps/ios/Shared/Views/Chat/ChatItemView.swift | 1 + apps/ios/Shared/Views/Chat/ChatView.swift | 7 +-- .../Views/ChatList/ChatListNavLink.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 46 +++++++++++-------- apps/ios/SimpleXChat/APITypes.swift | 4 ++ apps/ios/SimpleXChat/ChatTypes.swift | 23 +++++++++- 8 files changed, 69 insertions(+), 27 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index aef8711f34..85e66e893d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1285,6 +1285,12 @@ func processReceivedMsg(_ res: ChatResponse) async { m.removeChat(connection.id) } } + case let .contactDeletedByContact(user, contact): + if active(user) && contact.directOrUsed { + await MainActor.run { + m.updateContact(contact) + } + } case let .contactConnected(user, contact, _): if active(user) && contact.directOrUsed { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ec4cb90097..81412bf310 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -164,7 +164,7 @@ struct ChatInfoView: View { // synchronizeConnectionButtonForce() // } } - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) if let contactLink = contact.contactLink { Section { @@ -181,7 +181,7 @@ struct ChatInfoView: View { } } - if contact.ready { + if contact.ready && contact.active { Section("Servers") { networkStatusRow() .onTapGesture { @@ -192,8 +192,7 @@ struct ChatInfoView: View { alert = .switchAddressAlert } .disabled( - !contact.ready - || connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index a79047ebcc..31fe19c39a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -79,6 +79,7 @@ struct ChatItemContentView: View { case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) + case .rcvDirectEvent: eventItemView() case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText) case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) case .rcvGroupEvent: eventItemView() diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 81a063dcfc..389080efc5 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -150,7 +150,7 @@ struct ChatView: View { HStack { if contact.allowsFeature(.calls) { callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } Menu { if contact.allowsFeature(.calls) { @@ -159,11 +159,11 @@ struct ChatView: View { } label: { Label("Video call", systemImage: "video") } - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } searchButton() toggleNtfsButton(chat) - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } label: { Image(systemName: "ellipsis") } @@ -321,6 +321,7 @@ struct ChatView: View { @ViewBuilder private func connectingText() -> some View { if case let .direct(contact) = chat.chatInfo, !contact.ready, + contact.active, !contact.nextSendGrpInv { Text("connecting…") .font(.caption) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index e7580530b6..f445ae4b5d 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -65,7 +65,7 @@ struct ChatListNavLink: View { } Button { AlertManager.shared.showAlert( - contact.ready + contact.ready || !contact.active ? deleteContactAlert(chat.chatInfo) : deletePendingContactAlert(chat, contact) ) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 3ac8fada74..2eb6d9f6bd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -57,19 +57,26 @@ struct ChatPreviewView: View { } @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { - if case let .group(groupInfo) = chat.chatInfo { + switch chat.chatInfo { + case let .direct(contact): + if !contact.active { + inactiveIcon() + } else { + EmptyView() + } + case let .group(groupInfo): switch (groupInfo.membership.memberStatus) { - case .memLeft: groupInactiveIcon() - case .memRemoved: groupInactiveIcon() - case .memGroupDeleted: groupInactiveIcon() + case .memLeft: inactiveIcon() + case .memRemoved: inactiveIcon() + case .memGroupDeleted: inactiveIcon() default: EmptyView() } - } else { + default: EmptyView() } } - @ViewBuilder private func groupInactiveIcon() -> some View { + @ViewBuilder private func inactiveIcon() -> some View { Image(systemName: "multiply.circle.fill") .foregroundColor(.secondary.opacity(0.65)) .background(Circle().foregroundColor(Color(uiColor: .systemBackground))) @@ -80,7 +87,6 @@ struct ChatPreviewView: View { switch chat.chatInfo { case let .direct(contact): previewTitle(contact.verified == true ? verifiedIcon + t : t) - .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) case let .group(groupInfo): let v = previewTitle(t) switch (groupInfo.membership.memberStatus) { @@ -183,7 +189,7 @@ struct ChatPreviewView: View { if !contact.ready { if contact.nextSendGrpInv { chatPreviewInfoText("send direct message") - } else { + } else if contact.active { chatPreviewInfoText("connecting…") } } @@ -228,16 +234,20 @@ struct ChatPreviewView: View { @ViewBuilder private func chatStatusImage() -> some View { switch chat.chatInfo { case let .direct(contact): - switch (chatModel.contactNetworkStatus(contact)) { - case .connected: incognitoIcon(chat.chatInfo.incognito) - case .error: - Image(systemName: "exclamationmark.circle") - .resizable() - .scaledToFit() - .frame(width: 17, height: 17) - .foregroundColor(.secondary) - default: - ProgressView() + if contact.active { + switch (chatModel.contactNetworkStatus(contact)) { + case .connected: incognitoIcon(chat.chatInfo.incognito) + case .error: + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: 17, height: 17) + .foregroundColor(.secondary) + default: + ProgressView() + } + } else { + incognitoIcon(chat.chatInfo.incognito) } default: incognitoIcon(chat.chatInfo.incognito) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b0834f5715..951f726be9 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -462,6 +462,7 @@ public enum ChatResponse: Decodable, Error { case contactAlreadyExists(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) + case contactDeletedByContact(user: UserRef, contact: Contact) case chatCleared(user: UserRef, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) @@ -599,6 +600,7 @@ public enum ChatResponse: Decodable, Error { case .contactAlreadyExists: return "contactAlreadyExists" case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted" case .contactDeleted: return "contactDeleted" + case .contactDeletedByContact: return "contactDeletedByContact" case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" @@ -735,6 +737,7 @@ public enum ChatResponse: Decodable, Error { case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) + case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact)) case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) @@ -1420,6 +1423,7 @@ public enum ChatErrorType: Decodable { case invalidConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact) + case contactNotActive(contact: Contact) case contactDisabled(contact: Contact) case connectionDisabled(connection: Connection) case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c0ec048572..f9996d8400 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1373,6 +1373,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var activeConn: Connection public var viaGroup: Int64? public var contactUsed: Bool + public var contactStatus: ContactStatus public var chatSettings: ChatSettings public var userPreferences: Preferences public var mergedPreferences: ContactUserPreferences @@ -1384,8 +1385,9 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } public var ready: Bool { get { activeConn.connStatus == .ready } } + public var active: Bool { get { contactStatus == .active } } public var sendMsgEnabled: Bool { get { - (ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) + (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) || nextSendGrpInv } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } @@ -1430,6 +1432,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { profile: LocalProfile.sampleData, activeConn: Connection.sampleData, contactUsed: true, + contactStatus: .active, chatSettings: ChatSettings.defaults, userPreferences: Preferences.sampleData, mergedPreferences: ContactUserPreferences.sampleData, @@ -1439,6 +1442,11 @@ public struct Contact: Identifiable, Decodable, NamedChat { ) } +public enum ContactStatus: String, Decodable { + case active = "active" + case deleted = "deleted" +} + public struct ContactRef: Decodable, Equatable { var contactId: Int64 public var agentConnId: String @@ -2091,6 +2099,7 @@ public struct ChatItem: Identifiable, Decodable { case .rcvDecryptionError: return showNtfDir case .rcvGroupInvitation: return showNtfDir case .sndGroupInvitation: return showNtfDir + case .rcvDirectEvent: return false case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent): switch rcvGroupEvent { case .groupUpdated: return false @@ -2513,6 +2522,7 @@ public enum CIContent: Decodable, ItemContent { case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32) case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) + case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent) case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent) case sndGroupEvent(sndGroupEvent: SndGroupEvent) case rcvConnEvent(rcvConnEvent: RcvConnEvent) @@ -2542,6 +2552,7 @@ public enum CIContent: Decodable, ItemContent { case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text @@ -3195,6 +3206,16 @@ public enum CIGroupInvitationStatus: String, Decodable { case expired } +public enum RcvDirectEvent: Decodable { + case contactDeleted + + var text: String { + switch self { + case .contactDeleted: return NSLocalizedString("deleted contact", comment: "rcv direct event chat item") + } + } +} + public enum RcvGroupEvent: Decodable { case memberAdded(groupMemberId: Int64, profile: Profile) case memberConnected From ea319313f10c41127e604cee2391ce759aaab8b3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 27 Sep 2023 21:15:19 +0100 Subject: [PATCH 011/294] core: return error response when wrong passphrase is passed to start --- src/Simplex/Chat.hs | 26 +++++++++++++++----------- src/Simplex/Chat/Archive.hs | 26 +++++++++++++++----------- src/Simplex/Chat/Controller.hs | 3 --- src/Simplex/Chat/Core.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- tests/ChatTests/Direct.hs | 2 ++ 6 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3530fa82cb..a16cb98ae8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -34,6 +34,7 @@ import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace, toLower) +import Data.Composition ((.:)) import Data.Constraint (Dict (..)) import Data.Either (fromRight, rights) import Data.Fixed (div') @@ -217,8 +218,8 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen where configServers :: DefaultAgentServers configServers = - let smp' = fromMaybe (defaultServers.smp) (nonEmpty smpServers) - xftp' = fromMaybe (defaultServers.xftp) (nonEmpty xftpServers) + let smp' = fromMaybe defaultServers.smp (nonEmpty smpServers) + xftp' = fromMaybe defaultServers.xftp (nonEmpty xftpServers) in defaultServers {smp = smp', xftp = xftp', netCfg = networkConfig} agentServers :: ChatConfig -> IO InitialAgentServers agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do @@ -249,13 +250,9 @@ cfgServers p s = case p of SPSMP -> s.smp SPXFTP -> s.xftp -startChatController :: forall m. ChatMonad' m => ChatCtrlCfg -> m (Async ()) -startChatController ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey} = do - ChatController {chatStore, smpAgent} <- ask - forM_ openDBWithKey $ \(DBEncryptionKey dbKey) -> liftIO $ do - openSQLiteStore chatStore dbKey - openSQLiteStore (agentClientStore smpAgent) dbKey - resumeAgentClient smpAgent +startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ()) +startChatController subConns enableExpireCIs startXFTPWorkers = do + resumeAgentClient =<< asks smpAgent unless subConns $ chatWriteVar subscriptionMode SMOnlyCreate users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers) @@ -469,10 +466,17 @@ processChatCommand = \case checkDeleteChatUser user' withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_ - APIStartChat cfg -> withUser' $ \_ -> + APIStartChat ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey} -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ startChatController cfg $> CRChatStarted + _ -> checkStoreNotChanged $ do + forM_ openDBWithKey $ \(DBEncryptionKey dbKey) -> do + ChatController {chatStore, smpAgent} <- ask + open chatStore dbKey + open (agentClientStore smpAgent) dbKey + startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted + where + open = handleDBError DBErrorOpen .: openSQLiteStore APIStopChat closeStore -> do ask >>= (`stopChatController` closeStore) pure CRChatStopped diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 55bd31e51f..8d1b99328c 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Archive importArchive, deleteStorage, sqlCipherExport, + handleDBError, ) where @@ -139,17 +140,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D withDB (`SQL.exec` testSQL) DBErrorOpen atomically $ writeTVar dbEnc $ not (null key') where - withDB a err = - liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing) - `catch` checkSQLError - `catch` (\(e :: SomeException) -> sqliteError' e) - >>= mapM_ (throwDBError . err) - where - checkSQLError e = case SQL.sqlError e of - SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase - _ -> sqliteError' e - sqliteError' :: Show e => e -> m (Maybe SQLiteError) - sqliteError' = pure . Just . SQLiteError . show + withDB a err = handleDBError err $ bracket (SQL.open $ T.pack f) SQL.close a exportSQL = T.unlines $ keySQL key @@ -166,3 +157,16 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D "SELECT count(*) FROM sqlite_master;" ] keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] + +handleDBError :: forall m. ChatMonad m => (SQLiteError -> DatabaseError) -> IO () -> m () +handleDBError err a = + (liftIO a $> Nothing) + `catch` checkSQLError + `catch` (\(e :: SomeException) -> sqliteError' e) + >>= mapM_ (throwDBError . err) + where + checkSQLError e = case SQL.sqlError e of + SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase + _ -> sqliteError' e + sqliteError' :: Show e => e -> m (Maybe SQLiteError) + sqliteError' = pure . Just . SQLiteError . show diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2931a874ee..3f3dae94ff 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -629,9 +629,6 @@ data ChatCtrlCfg = ChatCtrlCfg } deriving (Show, Generic, FromJSON) -defChatCtrlCfg :: ChatCtrlCfg -defChatCtrlCfg = ChatCtrlCfg True True True Nothing - newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index b094130373..4af161ab41 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -35,7 +35,7 @@ runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController runSimplexChat ChatOpts {maintenance} u cc chat | maintenance = wait =<< async (chat u cc) | otherwise = do - a1 <- runReaderT (startChatController defChatCtrlCfg) cc + a1 <- runReaderT (startChatController True True True) cc a2 <- async $ chat u cc waitEither_ a1 a2 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 01bdfba95a..19d729bf7e 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1648,7 +1648,7 @@ viewChatError logLevel = \case DBErrorEncrypted -> ["error: chat database is already encrypted"] DBErrorPlaintext -> ["error: chat database is not encrypted"] DBErrorExport e -> ["error encrypting database: " <> sqliteError' e] - DBErrorOpen e -> ["error opening database after encryption: " <> sqliteError' e] + DBErrorOpen e -> ["error opening database: " <> sqliteError' e] e -> ["chat database error: " <> sShow e] ChatErrorAgent err entity_ -> case err of CMD PROHIBITED -> [withConnEntity <> "error: command is prohibited"] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 36e74e11f7..d9e8bac2f2 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -959,6 +959,8 @@ testDatabaseEncryption tmp = do alice <## "chat stopped" alice ##> "/db key wrongkey nextkey" alice <## "error encrypting database: wrong passphrase or invalid database file" + alice ##> "/_start key=wrongkey" + alice <## "error opening database: wrong passphrase or invalid database file" alice ##> "/_start key=mykey" alice <## "chat started" testChatWorking alice bob From 942e5eb8c49d8d28b51941f4f32f8b2cd610e54c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 27 Sep 2023 22:19:20 +0100 Subject: [PATCH 012/294] docs: update branches --- docs/CONTRIBUTING.md | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index dc18bebb02..0aa09c5166 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -38,9 +38,15 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t - `master` - branch for beta version releases (GHC 9.6.2). -- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7). +- `master-ghc8107` - branch for beta version releases (GHC 8.10.7). -- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7) – this branch should be the same as `master-android` except Nix configuration files. +- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7), same as `master-ghc8107` + +- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7). + +- `windows-ghc8107` - branch for windows core library build (GHC 8.10.7). + +`master-ios` and `windows-ghc8107` branches should be the same as `master-ghc8107` except Nix configuration files. **In simplexmq repo** @@ -54,24 +60,30 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t 2. If simplexmq repo was changed, to build mobile core libraries you need to merge its `master` branch into `master-ghc8107` branch. -3. To build Android core library: -- merge `master` branch to `master-android` branch. +3. To build core libraries for Android, iOS and windows: +- merge `master` branch to `master-ghc8107` branch. +- update `simplexmq` commit in `master-ghc8107` branch to the commit in `master-ghc8107` branch (probably, when resolving merge conflicts). - update code to be compatible with GHC 8.10.7 (see below). -- update `simplexmq` commit in `master-android` branch to the commit in `master-ghc8107` branch. - push to GitHub. -4. To build iOS core library, merge `master-android` branch to `master-ios` branch, and push to GitHub. +4. To build Android core library, merge `master-ghc8107` branch to `master-android` branch, and push to GitHub. -5. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. +5. To build iOS core library, merge `master-ghc8107` branch to `master-ios` branch, and push to GitHub. -6. After the public release to App Store and Play Store, merge: +6. To build windows core library, merge `master-ghc8107` branch to `windows-ghc8107` branch, and push to GitHub. + +7. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. + +8. After the public release to App Store and Play Store, merge: - `master` to `stable` -- `master` to `master-android` (and compile/update code) -- `master-android` to `master-ios` +- `master` to `master-ghc8107` (and compile/update code) +- `master-ghc8107` to `master-android` +- `master-ghc8107` to `master-ios` +- `master-ghc8107` to `windows-ghc8107` - `master-android` to `stable-android` - `master-ios` to `stable-ios` -7. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. +9. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. ## Differences between GHC 8.10.7 and GHC 9.6.2 From dea96df27bf7002b1d52942c59bf8c1981dd67e7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 28 Sep 2023 09:26:54 +0100 Subject: [PATCH 013/294] docs: update join team --- docs/JOIN_TEAM.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index 5a31b3e058..cf33df1ee7 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -15,31 +15,30 @@ We want to add up to 3 people to the team. ## Who we are looking for -### Systems Haskell engineer +### Application Haskell engineer -You are a servers/network/Haskell expert: -- network libraries. +You are an expert in language models, databases and Haskell: +- expert knowledge of SQL. - exception handling, concurrency, STM. - type systems - we use ad hoc dependent types a lot. -- strictness. -- has some expertise in network protocols, cryptography and general information security principles and approaches. +- experience integrating open-source language models. +- experience developing community-centric applications. - interested to build the next generation of messaging network. -You will be focussed mostly on our servers code, and will also contribute to the core client code written in Haskell. +You will be focussed mostly on our client applications, and will also contribute to the servers also written in Haskell. +### iOS / Mac engineer -### Product engineer (iOS) - -You are a product UX expert who designs great user experiences directly in iOS code: -- iOS and Mac platforms, including: - - SwiftUI and UIKit. - - extensions, including notification service extension and sharing extension. - - low level inter-process communication primitives for concurrency. +You are an expert in Apple platforms, including: +- iOS and Mac platform architecture. +- Swift and Objective-C. +- SwiftUI and UIKit. +- extensions, including notification service extension and sharing extension. +- low level inter-process communication primitives for concurrency. - interested about creating the next generation of UX for a communication/social network. Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin Jetpack Compose for our Android and desktop apps. - ## About you - **Passionate about joining SimpleX Chat team**: From 957f3b3eb0421096c59058f7870e7c16507956ae Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 28 Sep 2023 13:16:03 +0400 Subject: [PATCH 014/294] core: delete unused contact silently (#3140) --- src/Simplex/Chat.hs | 26 ++++++++++++++++---------- tests/ChatTests/Direct.hs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a16cb98ae8..822d6cd4be 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -4252,16 +4252,22 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xInfo c p' = void $ processContactProfileUpdate c p' True xDirectDel :: Contact -> RcvMessage -> MsgMeta -> m () - xDirectDel c msg msgMeta = do - checkIntegrityCreateItem (CDDirectRcv c) msgMeta - ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted - contactConns <- withStore $ \db -> getContactConnections db userId ct' - deleteAgentConnectionsAsync user $ map aConnId contactConns - forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted - let ct'' = ct' {activeConn = (contactConn ct') {connStatus = ConnDeleted}} :: Contact - ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) - toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) - toView $ CRContactDeletedByContact user ct'' + xDirectDel c msg msgMeta = + if directOrUsed c + then do + checkIntegrityCreateItem (CDDirectRcv c) msgMeta + ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted + contactConns <- withStore $ \db -> getContactConnections db userId ct' + deleteAgentConnectionsAsync user $ map aConnId contactConns + forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + let ct'' = ct' {activeConn = (contactConn ct') {connStatus = ConnDeleted}} :: Contact + ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) + toView $ CRContactDeletedByContact user ct'' + else do + contactConns <- withStore $ \db -> getContactConnections db userId c + deleteAgentConnectionsAsync user $ map aConnId contactConns + withStore' $ \db -> deleteContact db user c processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact processContactProfileUpdate c@Contact {profile = p} p' createItems diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index d9e8bac2f2..445a5ab998 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -31,6 +31,7 @@ chatDirectTests = do describe "direct messages" $ do describe "add contact and send/receive message" testAddContact it "deleting contact deletes profile" testDeleteContactDeletesProfile + it "unused contact is deleted silently" testDeleteUnusedContactSilent it "direct message quoted replies" testDirectMessageQuotedReply it "direct message update" testDirectMessageUpdate it "direct message edit history" testDirectMessageEditHistory @@ -214,6 +215,42 @@ testDeleteContactDeletesProfile = (bob FilePath -> IO () +testDeleteUnusedContactSilent = + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + bob `hasContactProfiles` ["bob", "alice", "cath"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + -- bob deletes cath, cath's bob contact is deleted silently + bob ##> "/d cath" + bob <## "cath: contact is deleted" + bob ##> "/contacts" + bob <## "alice (Alice)" + threadDelay 50000 + cath ##> "/contacts" + cath <## "alice (Alice)" + -- group messages work + alice #> "#team hello" + concurrentlyN_ + [ bob <# "#team alice> hello", + cath <# "#team alice> hello" + ] + bob #> "#team hi there" + concurrentlyN_ + [ alice <# "#team bob> hi there", + cath <# "#team bob> hi there" + ] + cath #> "#team hey" + concurrentlyN_ + [ alice <# "#team cath> hey", + bob <# "#team cath> hey" + ] + testDirectMessageQuotedReply :: HasCallStack => FilePath -> IO () testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ From 682dfe503cdcaea6cb16d6f9eabe6fdcdb6afd3d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 28 Sep 2023 13:52:43 +0400 Subject: [PATCH 015/294] android, desktop: notify contact about contact deletion (#3139) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/ChatModel.kt | 23 ++++++- .../chat/simplex/common/model/SimpleXAPI.kt | 10 +++ .../simplex/common/views/chat/ChatInfoView.kt | 5 +- .../simplex/common/views/chat/ChatView.kt | 15 ++-- .../common/views/chat/item/ChatItemView.kt | 1 + .../common/views/chatlist/ChatPreviewView.kt | 68 +++++++++++-------- .../commonMain/resources/MR/base/strings.xml | 3 + 7 files changed, 87 insertions(+), 38 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 887abe756b..0eb02515b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -797,6 +797,7 @@ data class Contact( val activeConn: Connection, val viaGroup: Long? = null, val contactUsed: Boolean, + val contactStatus: ContactStatus, val chatSettings: ChatSettings, val userPreferences: ChatPreferences, val mergedPreferences: ContactUserPreferences, @@ -809,8 +810,9 @@ data class Contact( override val id get() = "@$contactId" override val apiId get() = contactId override val ready get() = activeConn.connStatus == ConnStatus.Ready + val active get() = contactStatus == ContactStatus.Active override val sendMsgEnabled get() = - (ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) + (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent override val ntfsEnabled get() = chatSettings.enableNtfs @@ -859,6 +861,7 @@ data class Contact( profile = LocalProfile.sampleData, activeConn = Connection.sampleData, contactUsed = true, + contactStatus = ContactStatus.Active, chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), userPreferences = ChatPreferences.sampleData, mergedPreferences = ContactUserPreferences.sampleData, @@ -869,6 +872,12 @@ data class Contact( } } +@Serializable +enum class ContactStatus { + @SerialName("active") Active, + @SerialName("deleted") Deleted; +} + @Serializable class ContactRef( val contactId: Long, @@ -1471,6 +1480,7 @@ data class ChatItem ( is CIContent.RcvDecryptionError -> showNtfDir is CIContent.RcvGroupInvitation -> showNtfDir is CIContent.SndGroupInvitation -> showNtfDir + is CIContent.RcvDirectEventContent -> false is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is RcvGroupEvent.MemberAdded -> false is RcvGroupEvent.MemberConnected -> false @@ -1854,6 +1864,7 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null } @@ -1881,6 +1892,7 @@ sealed class CIContent: ItemContent { is RcvDecryptionError -> msgDecryptError.text is RcvGroupInvitation -> groupInvitation.text is SndGroupInvitation -> groupInvitation.text + is RcvDirectEventContent -> rcvDirectEvent.text is RcvGroupEventContent -> rcvGroupEvent.text is SndGroupEventContent -> sndGroupEvent.text is RcvConnEventContent -> rcvConnEvent.text @@ -2487,6 +2499,15 @@ sealed class MsgErrorType() { } } +@Serializable +sealed class RcvDirectEvent() { + @Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent() + + val text: String get() = when (this) { + is ContactDeleted -> generalGetString(MR.strings.rcv_direct_event_contact_deleted) + } +} + @Serializable sealed class RcvGroupEvent() { @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 87cac179ff..619788f6eb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1366,6 +1366,11 @@ object ChatController { chatModel.removeChat(r.connection.id) } } + is CR.ContactDeletedByContact -> { + if (active(r.user) && r.contact.directOrUsed) { + chatModel.updateContact(r.contact) + } + } is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(r.contact) @@ -3304,6 +3309,7 @@ sealed class CR { @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() + @Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @@ -3435,6 +3441,7 @@ sealed class CR { is ContactAlreadyExists -> "contactAlreadyExists" is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted" is ContactDeleted -> "contactDeleted" + is ContactDeletedByContact -> "contactDeletedByContact" is ChatCleared -> "chatCleared" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" @@ -3563,6 +3570,7 @@ sealed class CR { is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) is ContactDeleted -> withUser(user, json.encodeToString(contact)) + is ContactDeletedByContact -> withUser(user, json.encodeToString(contact)) is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) is UserProfileNoChange -> withUser(user, noDetails()) is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) @@ -3831,6 +3839,7 @@ sealed class ChatErrorType { is InvalidConnReq -> "invalidConnReq" is InvalidChatMessage -> "invalidChatMessage" is ContactNotReady -> "contactNotReady" + is ContactNotActive -> "contactNotActive" is ContactDisabled -> "contactDisabled" is ConnectionDisabled -> "connectionDisabled" is GroupUserRole -> "groupUserRole" @@ -3906,6 +3915,7 @@ sealed class ChatErrorType { @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() + @Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType() @Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType() @Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 5fcb90c1c9..1ba742b034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -291,7 +290,7 @@ fun ChatInfoLayout( SectionDividerSpaced() } - if (contact.ready) { + if (contact.ready && contact.active) { SectionView { if (connectionCode != null) { VerifyCodeButton(contact.verified, verifyClicked) @@ -318,7 +317,7 @@ fun ChatInfoLayout( SectionDividerSpaced() } - if (contact.ready) { + if (contact.ready && contact.active) { SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { SectionItemView({ AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 596a7426ad..b22b2f91c8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -118,7 +118,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - if (chat.chatInfo is ChatInfo.Direct && !chat.chatInfo.contact.ready && !chat.chatInfo.contact.nextSendGrpInv) { + if ( + chat.chatInfo is ChatInfo.Direct + && !chat.chatInfo.contact.ready + && chat.chatInfo.contact.active + && !chat.chatInfo.contact.nextSendGrpInv + ) { Text( generalGetString(MR.strings.contact_connection_pending), Modifier.padding(top = 4.dp), @@ -550,15 +555,15 @@ fun ChatInfoToolbar( showMenu.value = false startCall(CallMediaType.Audio) }, - enabled = chat.chatInfo.contact.ready) { + enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { Icon( painterResource(MR.images.ic_call_500), stringResource(MR.strings.icon_descr_more_button), - tint = if (chat.chatInfo.contact.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary ) } } - if (chat.chatInfo.contact.ready) { + if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { menuItems.add { ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { showMenu.value = false @@ -576,7 +581,7 @@ fun ChatInfoToolbar( } } } - if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready) || chat.chatInfo is ChatInfo.Group) { + if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) { val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } menuItems.add { ItemAction( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index e1d45ffb31..b5236e2491 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -352,6 +352,7 @@ fun ChatItemView( is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) + is CIContent.RcvDirectEventContent -> EventItemView() is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) { is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText()) is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 780e3515df..a5775d369b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -42,7 +42,7 @@ fun ChatPreviewView( val cInfo = chat.chatInfo @Composable - fun groupInactiveIcon() { + fun inactiveIcon() { Icon( painterResource(MR.images.ic_cancel_filled), stringResource(MR.strings.icon_descr_group_inactive), @@ -53,13 +53,19 @@ fun ChatPreviewView( @Composable fun chatPreviewImageOverlayIcon() { - if (cInfo is ChatInfo.Group) { - when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemLeft -> groupInactiveIcon() - GroupMemberStatus.MemRemoved -> groupInactiveIcon() - GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon() - else -> {} + when (cInfo) { + is ChatInfo.Direct -> + if (!cInfo.contact.active) { + inactiveIcon() + } + is ChatInfo.Group -> + when (cInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemLeft -> inactiveIcon() + GroupMemberStatus.MemRemoved -> inactiveIcon() + GroupMemberStatus.MemGroupDeleted -> inactiveIcon() + else -> {} } + else -> {} } } @@ -125,7 +131,7 @@ fun ChatPreviewView( if (cInfo.contact.verified) { VerifiedIcon() } - chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else MaterialTheme.colors.secondary) + chatPreviewTitleText() } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { @@ -174,7 +180,7 @@ fun ChatPreviewView( is ChatInfo.Direct -> if (cInfo.contact.nextSendGrpInv) { Text(stringResource(MR.strings.member_contact_send_direct_message), color = MaterialTheme.colors.secondary) - } else if (!cInfo.ready) { + } else if (!cInfo.ready && cInfo.contact.active) { Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary) } is ChatInfo.Group -> @@ -191,28 +197,32 @@ fun ChatPreviewView( @Composable fun chatStatusImage() { if (cInfo is ChatInfo.Direct) { - val descr = contactNetworkStatus?.statusString - when (contactNetworkStatus) { - is NetworkStatus.Connected -> - IncognitoIcon(chat.chatInfo.incognito) + if (cInfo.contact.active) { + val descr = contactNetworkStatus?.statusString + when (contactNetworkStatus) { + is NetworkStatus.Connected -> + IncognitoIcon(chat.chatInfo.incognito) - is NetworkStatus.Error -> - Icon( - painterResource(MR.images.ic_error), - contentDescription = descr, - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(19.dp) - ) + is NetworkStatus.Error -> + Icon( + painterResource(MR.images.ic_error), + contentDescription = descr, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(19.dp) + ) - else -> - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(15.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 1.5.dp - ) + else -> + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(15.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) + } + } else { + IncognitoIcon(chat.chatInfo.incognito) } } else { IncognitoIcon(chat.chatInfo.incognito) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index ab0d943f33..26e9948d6c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1105,6 +1105,9 @@ You rejected group invitation Group invitation expired + + deleted contact + invited %1$s connected From c1854b7d507708942397869d09b8828096e9d0d4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 28 Sep 2023 18:39:43 +0800 Subject: [PATCH 016/294] desktop: fix script for building the lib (#3141) --- scripts/desktop/build-lib-mac.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 8b0386473f..303e331549 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -117,7 +117,7 @@ for lib in $(find . -type f -name "*.$LIB_EXT"); do done done -LOCAL_DIRS=`for lib in $(find . -type f -name "*.$LIB_EXT"); do otool -l $lib | grep -E "/Users|/opt/|/usr/local" && echo $lib; done` +LOCAL_DIRS=`for lib in $(find . -type f -name "*.$LIB_EXT"); do otool -l $lib | grep -E "/Users|/opt/|/usr/local" && echo $lib || true; done` if [ -n "$LOCAL_DIRS" ]; then echo These libs still point to local directories: echo $LOCAL_DIRS From bc7baf560be0f6ec88bcba5b55f1b6dd5fa8a513 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:24:16 +0400 Subject: [PATCH 017/294] core: filter out connections of deleted contacts and group members on subscribe (#3144) --- src/Simplex/Chat.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 822d6cd4be..896edd26bb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2570,7 +2570,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts - let connIds = map contactConnId cts + let connIds = map contactConnId (filter contactActive cts) pure (connIds, M.fromList $ zip connIds cts) getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact) getUserContactLinkConns = do @@ -2580,7 +2580,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember) getGroupMemberConns = do gs <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserGroups") getUserGroups - let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) ms) gs + let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs pure (gs, map fst mPairs, M.fromList mPairs) getSndFileTransferConns :: m ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns = do From 1d34500fba510a01be73dad81d6fb9f7447e7a41 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:14:10 +0100 Subject: [PATCH 018/294] core: revert stop/close changes made for Windows (#3145) * Revert "core: return error response when wrong passphrase is passed to start" This reverts commit ea319313f10c41127e604cee2391ce759aaab8b3. * Revert "core: support closing/re-opening store on chat stop/start (#3132)" This reverts commit 3c7fc6b0ee1949dbe731bc11ad4e4809474ae7fd. --- .../chat/simplex/common/model/SimpleXAPI.kt | 25 ++++------- .../common/views/database/DatabaseView.kt | 2 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 43 ++++++------------- src/Simplex/Chat/Archive.hs | 28 ++++++------ src/Simplex/Chat/Controller.hs | 12 +----- src/Simplex/Chat/View.hs | 2 +- stack.yaml | 2 +- tests/ChatClient.hs | 2 +- tests/ChatTests/Direct.hs | 9 +--- 11 files changed, 42 insertions(+), 87 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 619788f6eb..4bb06afd8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -516,8 +516,8 @@ object ChatController { throw Exception("failed to delete the user ${r.responseType} ${r.details}") } - suspend fun apiStartChat(openDBWithKey: String? = null): Boolean { - val r = sendCmd(CC.StartChat(ChatCtrlCfg(subConns = true, enableExpireCIs = true, startXFTPWorkers = true, openDBWithKey = openDBWithKey))) + suspend fun apiStartChat(): Boolean { + val r = sendCmd(CC.StartChat(expire = true)) when (r) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -525,8 +525,8 @@ object ChatController { } } - suspend fun apiStopChat(closeStore: Boolean): Boolean { - val r = sendCmd(CC.ApiStopChat(closeStore)) + suspend fun apiStopChat(): Boolean { + val r = sendCmd(CC.ApiStopChat()) when (r) { is CR.ChatStopped -> return true else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}") @@ -1834,8 +1834,8 @@ sealed class CC { class ApiMuteUser(val userId: Long): CC() class ApiUnmuteUser(val userId: Long): CC() class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC() - class StartChat(val cfg: ChatCtrlCfg): CC() - class ApiStopChat(val closeStore: Boolean): CC() + class StartChat(val expire: Boolean): CC() + class ApiStopChat: CC() class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() @@ -1938,9 +1938,8 @@ sealed class CC { is ApiMuteUser -> "/_mute user $userId" is ApiUnmuteUser -> "/_unmute user $userId" is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}" -// is StartChat -> "/_start ${json.encodeToString(cfg)}" // this can be used with the new core - is StartChat -> "/_start subscribe=on expire=${onOff(cfg.enableExpireCIs)} xftp=on" - is ApiStopChat -> if (closeStore) "/_stop close" else "/_stop" + is StartChat -> "/_start subscribe=on expire=${onOff(expire)} xftp=on" + is ApiStopChat -> "/_stop" is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" @@ -2157,14 +2156,6 @@ sealed class CC { } } -@Serializable -data class ChatCtrlCfg ( - val subConns: Boolean, - val enableExpireCIs: Boolean, - val startXFTPWorkers: Boolean, - val openDBWithKey: String? -) - @Serializable data class NewUser( val profile: Profile?, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 3eb2e7d734..fa0f8f54d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -419,7 +419,7 @@ private fun stopChat(m: ChatModel) { } suspend fun stopChatAsync(m: ChatModel) { - m.controller.apiStopChat(false) + m.controller.apiStopChat() m.chatRunning.value = false } diff --git a/cabal.project b/cabal.project index b9753c9c00..b4024f088c 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: fda1284ae4b7c33cae2eb8ed0376a511aecc1d51 + tag: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index faa2401b17..26f4ea1122 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."fda1284ae4b7c33cae2eb8ed0376a511aecc1d51" = "1gq7scv9z8x3xhzl914xr46na0kkrqd1i743xbw69lyx33kj9xb5"; + "https://github.com/simplex-chat/simplexmq.git"."8d47f690838371bc848e4b31a4b09ef6bf67ccc5" = "1pwasv22ii3wy4xchaknlwczmy5ws7adx7gg2g58lxzrgdjm3650"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 896edd26bb..10f925471f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -34,7 +34,6 @@ import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace, toLower) -import Data.Composition ((.:)) import Data.Constraint (Dict (..)) import Data.Either (fromRight, rights) import Data.Fixed (div') @@ -84,7 +83,7 @@ import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentCl import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection, closeSQLiteStore, openSQLiteStore) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations @@ -218,8 +217,8 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen where configServers :: DefaultAgentServers configServers = - let smp' = fromMaybe defaultServers.smp (nonEmpty smpServers) - xftp' = fromMaybe defaultServers.xftp (nonEmpty xftpServers) + let smp' = fromMaybe (defaultServers.smp) (nonEmpty smpServers) + xftp' = fromMaybe (defaultServers.xftp) (nonEmpty xftpServers) in defaultServers {smp = smp', xftp = xftp', netCfg = networkConfig} agentServers :: ChatConfig -> IO InitialAgentServers agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do @@ -252,7 +251,7 @@ cfgServers p s = case p of startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ()) startChatController subConns enableExpireCIs startXFTPWorkers = do - resumeAgentClient =<< asks smpAgent + asks smpAgent >>= resumeAgentClient unless subConns $ chatWriteVar subscriptionMode SMOnlyCreate users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers) @@ -324,8 +323,8 @@ restoreCalls = do calls <- asks currentCalls atomically $ writeTVar calls callsMap -stopChatController :: forall m. MonadUnliftIO m => ChatController -> Bool -> m () -stopChatController ChatController {chatStore, smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} closeStore = do +stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles @@ -334,9 +333,6 @@ stopChatController ChatController {chatStore, smpAgent, agentAsync = s, sndFiles keys <- M.keys <$> readTVar expireCIFlags forM_ keys $ \k -> TM.insert k False expireCIFlags writeTVar s Nothing - when closeStore $ liftIO $ do - closeSQLiteStore chatStore - closeSQLiteStore $ agentClientStore smpAgent where closeFiles :: TVar (Map Int64 Handle) -> m () closeFiles files = do @@ -466,19 +462,12 @@ processChatCommand = \case checkDeleteChatUser user' withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_ - APIStartChat ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey} -> withUser' $ \_ -> + StartChat subConns enableExpireCIs startXFTPWorkers -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ do - forM_ openDBWithKey $ \(DBEncryptionKey dbKey) -> do - ChatController {chatStore, smpAgent} <- ask - open chatStore dbKey - open (agentClientStore smpAgent) dbKey - startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted - where - open = handleDBError DBErrorOpen .: openSQLiteStore - APIStopChat closeStore -> do - ask >>= (`stopChatController` closeStore) + _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted + APIStopChat -> do + ask >>= stopChatController pure CRChatStopped APIActivateChat -> withUser $ \_ -> do restoreCalls @@ -5411,9 +5400,9 @@ chatCommandP = "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)), "/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)), ("/user" <|> "/u") $> ShowActiveUser, - "/_start" *> (APIStartChat <$> ((A.space *> jsonP) <|> chatCtrlCfgP)), - "/_stop close" $> APIStopChat {closeStore = True}, - "/_stop" $> APIStopChat False, + "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP <* " xftp=" <*> onOffP), + "/_start" $> StartChat True True True, + "/_stop" $> APIStopChat, "/_app activate" $> APIActivateChat, "/_app suspend " *> (APISuspendChat <$> A.decimal), "/_resubscribe all" $> ResubscribeAllConnections, @@ -5641,12 +5630,6 @@ chatCommandP = ] where choice = A.choice . map (\p -> p <* A.takeWhile (== ' ') <* A.endOfInput) - chatCtrlCfgP = do - subConns <- (" subscribe=" *> onOffP) <|> pure True - enableExpireCIs <- (" expire=" *> onOffP) <|> pure True - startXFTPWorkers <- (" xftp=" *> onOffP) <|> pure True - openDBWithKey <- optional $ " key=" *> dbKeyP - pure ChatCtrlCfg {subConns, enableExpireCIs, startXFTPWorkers, openDBWithKey} incognitoP = (A.space *> ("incognito" <|> "i")) $> True <|> pure False incognitoOnOffP = (A.space *> "incognito=" *> onOffP) <|> pure False imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,") diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 8d1b99328c..f8fa0d152a 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -9,7 +9,6 @@ module Simplex.Chat.Archive importArchive, deleteStorage, sqlCipherExport, - handleDBError, ) where @@ -125,7 +124,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D checkFile `with` fs backup `with` fs (export chatDb chatEncrypted >> export agentDb agentEncrypted) - `catchChatError` \e -> tryChatError (restore `with` fs) >> throwError e + `catchChatError` \e -> (restore `with` fs) >> throwError e where action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb backup f = copyFile f (f <> ".bak") @@ -140,7 +139,17 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D withDB (`SQL.exec` testSQL) DBErrorOpen atomically $ writeTVar dbEnc $ not (null key') where - withDB a err = handleDBError err $ bracket (SQL.open $ T.pack f) SQL.close a + withDB a err = + liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing) + `catch` checkSQLError + `catch` (\(e :: SomeException) -> sqliteError' e) + >>= mapM_ (throwDBError . err) + where + checkSQLError e = case SQL.sqlError e of + SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase + _ -> sqliteError' e + sqliteError' :: Show e => e -> m (Maybe SQLiteError) + sqliteError' = pure . Just . SQLiteError . show exportSQL = T.unlines $ keySQL key @@ -157,16 +166,3 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D "SELECT count(*) FROM sqlite_master;" ] keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] - -handleDBError :: forall m. ChatMonad m => (SQLiteError -> DatabaseError) -> IO () -> m () -handleDBError err a = - (liftIO a $> Nothing) - `catch` checkSQLError - `catch` (\(e :: SomeException) -> sqliteError' e) - >>= mapM_ (throwDBError . err) - where - checkSQLError e = case SQL.sqlError e of - SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase - _ -> sqliteError' e - sqliteError' :: Show e => e -> m (Maybe SQLiteError) - sqliteError' = pure . Just . SQLiteError . show diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3f3dae94ff..122a4be3f3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -221,8 +221,8 @@ data ChatCommand | UnmuteUser | APIDeleteUser UserId Bool (Maybe UserPwd) | DeleteUser UserName Bool (Maybe UserPwd) - | APIStartChat ChatCtrlCfg - | APIStopChat {closeStore :: Bool} + | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool, startXFTPWorkers :: Bool} + | APIStopChat | APIActivateChat | APISuspendChat {suspendTimeout :: Int} | ResubscribeAllConnections @@ -621,14 +621,6 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" -data ChatCtrlCfg = ChatCtrlCfg - { subConns :: Bool, - enableExpireCIs :: Bool, - startXFTPWorkers :: Bool, - openDBWithKey :: Maybe DBEncryptionKey - } - deriving (Show, Generic, FromJSON) - newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 19d729bf7e..01bdfba95a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1648,7 +1648,7 @@ viewChatError logLevel = \case DBErrorEncrypted -> ["error: chat database is already encrypted"] DBErrorPlaintext -> ["error: chat database is not encrypted"] DBErrorExport e -> ["error encrypting database: " <> sqliteError' e] - DBErrorOpen e -> ["error opening database: " <> sqliteError' e] + DBErrorOpen e -> ["error opening database after encryption: " <> sqliteError' e] e -> ["chat database error: " <> sShow e] ChatErrorAgent err entity_ -> case err of CMD PROHIBITED -> [withConnEntity <> "error: command is prohibited"] diff --git a/stack.yaml b/stack.yaml index a466178ced..0840970e49 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: fda1284ae4b7c33cae2eb8ed0376a511aecc1d51 + commit: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d947fb63bd..7da5263253 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -171,7 +171,7 @@ startTestChat_ db cfg opts user = do stopTestChat :: TestCC -> IO () stopTestChat TestCC {chatController = cc, chatAsync, termAsync} = do - stopChatController cc False + stopChatController cc uninterruptibleCancel termAsync uninterruptibleCancel chatAsync threadDelay 200000 diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 445a5ab998..5c4d96cc94 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -992,17 +992,10 @@ testDatabaseEncryption tmp = do alice ##> "/_start" alice <## "chat started" testChatWorking alice bob - alice ##> "/_stop close" + alice ##> "/_stop" alice <## "chat stopped" alice ##> "/db key wrongkey nextkey" alice <## "error encrypting database: wrong passphrase or invalid database file" - alice ##> "/_start key=wrongkey" - alice <## "error opening database: wrong passphrase or invalid database file" - alice ##> "/_start key=mykey" - alice <## "chat started" - testChatWorking alice bob - alice ##> "/_stop close" - alice <## "chat stopped" alice ##> "/db key mykey nextkey" alice <## "ok" alice ##> "/_db encryption {\"currentKey\":\"nextkey\",\"newKey\":\"anotherkey\"}" From af2df8d4892216719cf9745cfef1e2834b980c89 Mon Sep 17 00:00:00 2001 From: IC Rainbow Date: Fri, 29 Sep 2023 14:56:56 +0300 Subject: [PATCH 019/294] Rewrite remote controller --- src/Simplex/Chat.hs | 110 ++--------------- src/Simplex/Chat/Controller.hs | 53 +++++++- .../Migrations/M20230922_remote_controller.hs | 2 +- src/Simplex/Chat/Remote.hs | 113 ++++++++++++++++-- src/Simplex/Chat/Remote/Discovery.hs | 89 +++++++------- src/Simplex/Chat/Remote/Types.hs | 8 +- src/Simplex/Chat/Store/Remote.hs | 7 +- 7 files changed, 216 insertions(+), 166 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 849bff97fd..ef2f95f67d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -19,7 +19,6 @@ module Simplex.Chat where import Control.Applicative (optional, (<|>)) import Control.Concurrent.STM (retry) -import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad import Control.Monad.Except @@ -63,7 +62,6 @@ import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Remote -import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Connections @@ -72,7 +70,6 @@ import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles -import Simplex.Chat.Store.Remote import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -367,19 +364,6 @@ execRemoteCommand u rh scmd = either (CRChatCmdError u) id <$> runExceptT (withR parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace --- | Emit local events. -toView :: ChatMonad' m => ChatResponse -> m () -toView = toView_ Nothing - --- | Used by transport to mark remote events with source. -toViewRemote :: ChatMonad' m => RemoteHostId -> ChatResponse -> m () -toViewRemote = toView_ . Just - -toView_ :: ChatMonad' m => Maybe RemoteHostId -> ChatResponse -> m () -toView_ rh event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, rh, event) - -- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case @@ -1852,69 +1836,16 @@ processChatCommand = \case p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} CreateRemoteHost _displayName -> pure $ chatCmdError Nothing "not supported" ListRemoteHosts -> pure $ chatCmdError Nothing "not supported" - StartRemoteHost rh -> do - RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" - (fingerprint :: ByteString, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) - cleanup <- toIO $ chatModifyVar remoteHostSessions (M.delete rh) - Discovery.runAnnouncer cleanup fingerprint sessionCreds >>= \case - Left todo'err -> pure $ chatCmdError Nothing "TODO: Some HTTP2 error" - Right ctrlClient -> do - chatModifyVar remoteHostSessions $ M.insert rh RemoteHostSession {storePath, ctrlClient} - pure $ CRRemoteHostStarted rh + StartRemoteHost rh -> startRemoteHost rh StopRemoteHost rh -> closeRemoteHostSession rh $> CRRemoteHostStopped rh DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + StartRemoteCtrl -> startRemoteCtrl + ConfirmRemoteCtrl rc -> confirmRemoteCtrl rc + RejectRemoteCtrl rc -> rejectRemoteCtrl rc + StopRemoteCtrl rc -> stopRemoteCtrl rc RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" - StartRemoteCtrl -> - chatReadVar remoteCtrlSession >>= \case - Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy - Nothing -> do - uio <- askUnliftIO - accepted <- newEmptyTMVarIO - let getControllers = unliftIO uio $ withStore' $ \db -> - map (\RemoteCtrl{remoteCtrlId, fingerprint} -> (fingerprint, remoteCtrlId)) <$> getRemoteCtrls (DB.conn db) - let started remoteCtrlId = unliftIO uio $ do - withStore' (\db -> getRemoteCtrl (DB.conn db) remoteCtrlId) >>= \case - Nothing -> pure False - Just RemoteCtrl{displayName, accepted=resolution} -> case resolution of - Nothing -> do - -- started/finished wrapper is synchronous, running HTTP server can be delayed here until UI processes the first contact dialogue - toView $ CRRemoteCtrlFirstContact {remoteCtrlId, displayName} - atomically $ takeTMVar accepted - Just known -> atomically $ putTMVar accepted known $> known - let finished remoteCtrlId todo'error = unliftIO uio $ do - chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlDisconnected {remoteCtrlId} - let process rc req = unliftIO uio $ processControllerCommand rc req - ctrlAsync <- async . liftIO $ Discovery.runDiscoverer getControllers started finished process - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync, accepted} - pure CRRemoteCtrlStarted - ConfirmRemoteCtrl remoteCtrlId -> do - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {accepted} -> do - withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId True - atomically $ putTMVar accepted True - pure $ CRRemoteCtrlAccepted {remoteCtrlId} - RejectRemoteCtrl remoteCtrlId -> do - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {accepted} -> do - withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId False - atomically $ putTMVar accepted False - pure $ CRRemoteCtrlRejected {remoteCtrlId} - StopRemoteCtrl remoteCtrlId -> - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {ctrlAsync} -> do - cancel ctrlAsync - pure $ CRRemoteCtrlDisconnected {remoteCtrlId} - DisposeRemoteCtrl remoteCtrlId -> - chatReadVar remoteCtrlSession >>= \case - Nothing -> do - withStore' $ \db -> deleteRemoteCtrl (DB.conn db) remoteCtrlId - pure $ CRRemoteCtrlDisposed {remoteCtrlId} - Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy + DisposeRemoteCtrl rc -> disposeRemoteCtrl rc QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) @@ -5410,33 +5341,6 @@ withAgent action = >>= runExceptT . action >>= liftEither . first (`ChatErrorAgent` Nothing) -withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a -withStore' action = withStore $ liftIO . action - -withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a -withStore = withStoreCtx Nothing - -withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a -withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action - -withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a -withStoreCtx ctx_ action = do - ChatController {chatStore} <- ask - liftEitherError ChatErrorStore $ case ctx_ of - Nothing -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" - -- uncomment to debug store performance - -- Just ctx -> do - -- t1 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx - -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") - -- t2 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) - -- pure r - Just _ -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" - where - handleInternal :: String -> E.SomeException -> IO (Either StoreError a) - handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr - chatCommandP :: Parser ChatCommand chatCommandP = choice @@ -5689,8 +5593,8 @@ chatCommandP = "/start remote host " *> (StartRemoteHost <$> A.decimal), "/stop remote host " *> (StopRemoteHost <$> A.decimal), "/dispose remote host " *> (DisposeRemoteHost <$> A.decimal), - "/register remote ctrl " *> (RegisterRemoteCtrl <$> textP <*> remoteHostOOBP), "/start remote ctrl" $> StartRemoteCtrl, + "/register remote ctrl " *> (RegisterRemoteCtrl <$> textP <*> remoteHostOOBP), "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), "/stop remote ctrl " *> (StopRemoteCtrl <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 82a1cbcede..0598bba8f1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -47,7 +47,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol import Simplex.Chat.Remote.Types -import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) @@ -57,6 +57,8 @@ import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -67,7 +69,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors, (<$$>)) +import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import System.IO (Handle) import System.Mem.Weak (Weak) @@ -603,11 +605,14 @@ data ChatResponse | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlStarted - | CRRemoteCtrlFirstContact {remoteCtrlId :: RemoteCtrlId, displayName :: Text} + | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation + | CRRemoteCtrlFound {remoteCtrl::RemoteCtrl} -- registered fingerprint, may connect + -- | CRRemoteCtrlFirstContact {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} - | CRRemoteCtrlDisconnected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlStopped {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlDisposed {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -1106,3 +1111,43 @@ data ArchiveError instance ToJSON ArchiveError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" + +-- | Emit local events. +toView :: ChatMonad' m => ChatResponse -> m () +toView = toView_ Nothing + +-- | Used by transport to mark remote events with source. +toViewRemote :: ChatMonad' m => RemoteHostId -> ChatResponse -> m () +toViewRemote = toView_ . Just + +toView_ :: ChatMonad' m => Maybe RemoteHostId -> ChatResponse -> m () +toView_ rh event = do + q <- asks outputQ + atomically $ writeTBQueue q (Nothing, rh, event) + +withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a +withStore' action = withStore $ liftIO . action + +withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a +withStore = withStoreCtx Nothing + +withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a +withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action + +withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a +withStoreCtx ctx_ action = do + ChatController {chatStore} <- ask + liftEitherError ChatErrorStore $ case ctx_ of + Nothing -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" + -- uncomment to debug store performance + -- Just ctx -> do + -- t1 <- liftIO getCurrentTime + -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx + -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") + -- t2 <- liftIO getCurrentTime + -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) + -- pure r + Just _ -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" + where + handleInternal :: String -> SomeException -> IO (Either StoreError a) + handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs index 4890bfc22a..d2ca386b0e 100644 --- a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs +++ b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs @@ -20,7 +20,7 @@ CREATE TABLE remote_controllers ( -- controllers known to a hosting app remote_controller_id INTEGER PRIMARY KEY, display_name TEXT NOT NULL, fingerprint BLOB NOT NULL, - accepted INTEGER + accepted INTEGER -- unknown/rejected/confirmed ); |] diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 8e2bedc979..4671f43537 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -15,14 +15,23 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.Map.Strict as M import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP2.Client as HTTP2Client +import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller +import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Remote import Simplex.Chat.Types +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 import Simplex.Messaging.Util (bshow) import System.Directory (getFileSize) +import UnliftIO withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a withRemoteHostSession remoteHostId action = do @@ -30,6 +39,17 @@ withRemoteHostSession remoteHostId action = do where err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing +startRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse +startRemoteHost remoteHostId = do + RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" + (fingerprint :: ByteString, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) + cleanup <- toIO $ chatModifyVar remoteHostSessions (M.delete remoteHostId) + Discovery.runAnnouncer cleanup fingerprint sessionCreds >>= \case + Left todo'err -> pure $ chatCmdError Nothing "TODO: Some HTTP2 error" + Right ctrlClient -> do + chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSession {storePath, ctrlClient} + pure $ CRRemoteHostStarted remoteHostId + closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () closeRemoteHostSession rh = withRemoteHostSession rh (liftIO . HTTP2.closeHTTP2Client . ctrlClient) @@ -68,10 +88,10 @@ relayCommand RemoteHostSession {ctrlClient} s = storeRemoteFile :: (ChatMonad m) => RemoteHostSession -> FilePath -> m ChatResponse storeRemoteFile RemoteHostSession {ctrlClient} localFile = do postFile Nothing ctrlClient "/store" mempty localFile >>= \case - Left e -> error "TODO: http2chatError" + Left todo'err -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {response} -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of Just 200 -> pure $ CRCmdOk Nothing - unexpected -> error "TODO: http2chatError" + todo'notOk -> error "TODO: http2chatError" where postFile timeout c path hs file = liftIO $ do fileSize <- fromIntegral <$> getFileSize file @@ -95,11 +115,88 @@ sum2tagged = \case J.Object todo'convert -> J.Object todo'convert skip -> skip --- withRemoteCtrlSession :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrlSession -> m a) -> m a --- withRemoteCtrlSession remoteCtrlId action = do --- chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteCtrlId --- where --- err = throwError $ ChatErrorRemoteCtrl (Just remoteCtrlId) RCMissing - processControllerCommand :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () processControllerCommand rc req = error "TODO: processControllerCommand" + +-- * ChatRequest handlers + +startRemoteCtrl :: (ChatMonad m) => m ChatResponse +startRemoteCtrl = + chatReadVar remoteCtrlSession >>= \case + Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy + Nothing -> do + accepted <- newEmptyTMVarIO + discovered <- newTVarIO mempty + listener <- async $ discoverRemoteCtrls discovered + _supervisor <- async $ do + uiEvent <- async $ atomically $ readTMVar accepted + waitEitherCatchCancel listener uiEvent >>= \case + Left _ -> pure () -- discover got cancelled or crashed on some UDP error + Right (Left _) -> pure () -- readTMVar blocked indefinitely (should not happen) + Right (Right remoteCtrlId) -> do + -- got connection confirmation + (source, fingerprint) <- + atomically $ + TM.lookup remoteCtrlId discovered >>= \case + Nothing -> error "Session accepted without getting registered" + Just found -> found <$ writeTVar discovered mempty -- flush unused sources + host <- async $ runRemoteHost remoteCtrlId source fingerprint + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = host, accepted} + _ <- waitCatch host + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped {remoteCtrlId} + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = listener, accepted} + pure CRRemoteCtrlStarted + +discoverRemoteCtrls :: (ChatMonad m) => TM.TMap RemoteCtrlId (TransportHost, C.KeyHash) -> m () +discoverRemoteCtrls discovered = Discovery.openListener >>= go + where + go sock = + Discovery.recvAnnounce sock >>= \case + (SockAddrInet _port addr, invite) -> case strDecode invite of + Left _ -> go sock -- ignore malformed datagrams + Right fingerprint -> do + withStore' (\db -> getRemoteCtrlByFingerprint (DB.conn db) fingerprint) >>= \case + Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint + Just found@RemoteCtrl {remoteCtrlId} -> do + atomically $ TM.insert remoteCtrlId (THIPv4 (hostAddressToTuple addr), fingerprint) discovered + toView $ CRRemoteCtrlFound found + _nonV4 -> go sock + +runRemoteHost :: (ChatMonad m) => RemoteCtrlId -> TransportHost -> C.KeyHash -> m () +runRemoteHost remoteCtrlId remoteCtrlHost fingerprint = + Discovery.connectSessionHost remoteCtrlHost fingerprint $ Discovery.attachServer (processControllerCommand remoteCtrlId) + +confirmRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +confirmRemoteCtrl remoteCtrlId = + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {accepted} -> do + withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId True + atomically $ putTMVar accepted remoteCtrlId -- the remote host can now proceed with connection + pure $ CRRemoteCtrlAccepted {remoteCtrlId} + +rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +rejectRemoteCtrl remoteCtrlId = + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {ctrlAsync} -> do + withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId False + cancel ctrlAsync + pure $ CRRemoteCtrlRejected {remoteCtrlId} + +stopRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +stopRemoteCtrl remoteCtrlId = + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just RemoteCtrlSession {ctrlAsync} -> do + cancel ctrlAsync + pure CRRemoteCtrlStopped {remoteCtrlId} + +disposeRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +disposeRemoteCtrl remoteCtrlId = + chatReadVar remoteCtrlSession >>= \case + Nothing -> do + withStore' $ \db -> deleteRemoteCtrl (DB.conn db) remoteCtrlId + pure $ CRRemoteCtrlDisposed {remoteCtrlId} + Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index cb668677f5..f04d0a008a 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -2,14 +2,22 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} module Simplex.Chat.Remote.Discovery - ( runAnnouncer, - runDiscoverer, + ( -- * Announce + runAnnouncer, + + -- * Discovery + openListener, + recvAnnounce, + connectSessionHost, + attachServer, ) where import Control.Monad +import Data.ByteString (ByteString) import Data.Default (def) import Data.String (IsString) import Debug.Trace @@ -28,6 +36,13 @@ import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTran import UnliftIO import UnliftIO.Concurrent +-- | Link-local broadcast address. +pattern BROADCAST_ADDR_V4 :: (IsString a, Eq a) => a +pattern BROADCAST_ADDR_V4 = "255.255.255.255" + +pattern BROADCAST_PORT :: (IsString a, Eq a) => a +pattern BROADCAST_PORT = "5226" + runAnnouncer :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) runAnnouncer finished invite credentials = do started <- newEmptyTMVarIO @@ -40,7 +55,7 @@ runAnnouncer finished invite credentials = do TLS.serverSupported = supportedParameters } httpClient <- newEmptyMVar - liftIO $ runTransportServer started partyPort serverParams defaultTransportServerConfig (run aPid httpClient) + liftIO $ runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig (run aPid httpClient) takeMVar httpClient where announcer started inviteBS = do @@ -48,10 +63,10 @@ runAnnouncer finished invite credentials = do False -> error "Server not started?.." True -> liftIO $ do - traceM $ "TCP server started at " <> partyPort - sock <- UDP.clientSocket broadcastAddrV4 partyPort False + traceM $ "TCP server started at " <> BROADCAST_PORT + sock <- UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 - traceM $ "UDP announce started at " <> broadcastAddrV4 <> ":" <> partyPort + traceM $ "UDP announce started at " <> BROADCAST_ADDR_V4 <> ":" <> BROADCAST_PORT traceM $ "Server invite: " <> show inviteBS forever $ do UDP.send sock inviteBS @@ -61,47 +76,25 @@ runAnnouncer finished invite credentials = do run aPid clientVar tls = do cancel aPid let partyHost = "255.255.255.255" -- XXX: get from tls somehow? not required as host verification is disabled. - attachHTTP2Client defaultHTTP2ClientConfig partyHost partyPort finished defaultHTTP2BufferSize tls >>= putMVar clientVar + attachHTTP2Client defaultHTTP2ClientConfig partyHost BROADCAST_PORT finished defaultHTTP2BufferSize tls >>= putMVar clientVar --- | Link-local broadcast address. -broadcastAddrV4 :: (IsString a) => a -broadcastAddrV4 = "255.255.255.255" - -partyPort :: (IsString a) => a -partyPort = "5226" -- XXX: should be `0` or something, to get a random port and announce it - -runDiscoverer :: IO [(C.KeyHash, ctx)] -> (ctx -> IO Bool) -> (ctx -> Maybe SomeException -> IO ()) -> (ctx -> HTTP2Request -> IO ()) -> IO () -runDiscoverer getFingerprints started finished processRequest = do - sock <- UDP.serverSocket (broadcastAddrV4, read partyPort) +openListener :: (MonadIO m) => m UDP.ListenSocket +openListener = liftIO $ do + sock <- UDP.serverSocket (BROADCAST_ADDR_V4, read BROADCAST_PORT) N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 - traceM $ "runDiscoverer: " <> show sock - go sock - where - go sock = do - (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock - case strDecode invite of - Left err -> do - traceM $ "Inivite decode error: " <> err - go sock - Right inviteHash -> do - expected <- getFingerprints - case lookup inviteHash expected of - Nothing -> do - traceM $ "Unexpected invite: " <> show (invite, source) - go sock - Just ctx -> do - host <- case source of - N.SockAddrInet _port addr -> do - pure $ THIPv4 (N.hostAddressToTuple addr) - unexpected -> - -- TODO: actually, Apple mandates IPv6 support - fail $ "Discoverer: expected an IPv4 party, got " <> show unexpected - runTransportClient defaultTransportClientConfig Nothing host partyPort (Just inviteHash) $ \tls -> do - accepted <- started ctx - if not accepted - then go sock -- Ignore rejected invites and wait for another - else do - res <- try $ runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do - reqBody <- getHTTP2Body r 16384 - processRequest ctx HTTP2Request {sessionId, request = r, reqBody, sendResponse} - finished ctx $ either Just (\() -> Nothing) res + pure sock + +recvAnnounce :: (MonadIO m) => UDP.ListenSocket -> m (N.SockAddr, ByteString) +recvAnnounce sock = liftIO $ do + (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock + pure (source, invite) + +connectSessionHost :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a +connectSessionHost host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host BROADCAST_PORT (Just caFingerprint) + +attachServer :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () +attachServer processRequest tls = do + withRunInIO $ \unlift -> + runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + reqBody <- getHTTP2Body r defaultHTTP2BufferSize + unlift $ processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 53f73c3389..fa9b3fb359 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,11 +1,15 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} module Simplex.Chat.Remote.Types where import Control.Concurrent.Async (Async) +import Data.Aeson (ToJSON) import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) +import GHC.Generics (Generic) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import UnliftIO.STM @@ -22,6 +26,7 @@ data RemoteHost = RemoteHost -- | Credentials signing key for root and session certs caKey :: C.Key } + deriving (Show) type RemoteCtrlId = Int64 @@ -31,6 +36,7 @@ data RemoteCtrl = RemoteCtrl fingerprint :: C.KeyHash, accepted :: Maybe Bool } + deriving (Show, Generic, ToJSON) data RemoteHostSession = RemoteHostSession { -- | Path for local resources to be synchronized with host @@ -41,5 +47,5 @@ data RemoteHostSession = RemoteHostSession data RemoteCtrlSession = RemoteCtrlSession { -- | Server side of transport to process remote commands and forward notifications ctrlAsync :: Async (), - accepted :: TMVar Bool + accepted :: TMVar RemoteCtrlId } diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index f185000d88..591f346bee 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -37,6 +37,11 @@ getRemoteCtrl db remoteCtrlId = maybeFirstRow toRemoteCtrl $ DB.query db (remoteCtrlQuery <> "WHERE remote_controller_id = ?") (DB.Only remoteCtrlId) +getRemoteCtrlByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteCtrl) +getRemoteCtrlByFingerprint db fingerprint = + maybeFirstRow toRemoteCtrl $ + DB.query db (remoteCtrlQuery <> "WHERE fingerprint = ?") (DB.Only fingerprint) + remoteCtrlQuery :: DB.Query remoteCtrlQuery = "SELECT remote_controller_id, display_name, fingerprint, accepted FROM remote_controllers" @@ -46,7 +51,7 @@ toRemoteCtrl (remoteCtrlId, displayName, fingerprint, accepted) = markRemoteCtrlResolution :: DB.Connection -> RemoteCtrlId -> Bool -> IO () markRemoteCtrlResolution db remoteCtrlId accepted = - DB.execute db "UPDATE remote_controllers SET accepted = ? WHERE remote_controller_id = ?" (accepted, remoteCtrlId) + DB.execute db "UPDATE remote_controllers SET accepted = ? WHERE remote_controller_id = ? AND accepted IS NULL" (accepted, remoteCtrlId) deleteRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO () deleteRemoteCtrl db remoteCtrlId = From 70a65e8969a67905a36116b04806aa7bd7d0c800 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 29 Sep 2023 13:09:48 +0100 Subject: [PATCH 020/294] core: close stores before import/delete/encryption operations to make compatible with windows, make encryption more resilient (#3146) * core: close stores before import/delete/encryption operations to make compatible with windows, make encryption more resilient * remove file names * do not remove files if already removed --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Archive.hs | 86 +++++++++++++++++++++---------------- stack.yaml | 2 +- 4 files changed, 52 insertions(+), 40 deletions(-) diff --git a/cabal.project b/cabal.project index b4024f088c..af664652db 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + tag: ec1b72cb8013a65a5d9783104a47ae44f5730089 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26f4ea1122..b6ca36e313 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8d47f690838371bc848e4b31a4b09ef6bf67ccc5" = "1pwasv22ii3wy4xchaknlwczmy5ws7adx7gg2g58lxzrgdjm3650"; + "https://github.com/simplex-chat/simplexmq.git"."ec1b72cb8013a65a5d9783104a47ae44f5730089" = "1lz5rvgxp242zg95r9zd9j50y45314cf8nfpjg1qsa55nrk2w19b"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index f8fa0d152a..e0de971bda 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -21,7 +21,7 @@ import qualified Data.Text as T import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller import Simplex.Messaging.Agent.Client (agentClientStore) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString, closeSQLiteStore) import Simplex.Messaging.Util import System.FilePath import UnliftIO.Directory @@ -42,9 +42,9 @@ archiveFilesFolder = "simplex_v1_files" exportArchive :: ChatMonad m => ArchiveConfig -> m () exportArchive cfg@ArchiveConfig {archivePath, disableCompression} = withTempDir cfg "simplex-chat." $ \dir -> do - StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - copyFile chatDb $ dir archiveChatDbFile - copyFile agentDb $ dir archiveAgentDbFile + StorageFiles {chatStore, agentStore, filesPath} <- storageFiles + copyFile (dbFilePath chatStore) $ dir archiveChatDbFile + copyFile (dbFilePath agentStore) $ dir archiveAgentDbFile forM_ filesPath $ \fp -> copyDirectoryFiles fp $ dir archiveFilesFolder let method = if disableCompression == Just True then Z.Store else Z.Deflate @@ -54,11 +54,11 @@ importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] importArchive cfg@ArchiveConfig {archivePath} = withTempDir cfg "simplex-chat." $ \dir -> do Z.withArchive archivePath $ Z.unpackInto dir - StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - backup chatDb - backup agentDb - copyFile (dir archiveChatDbFile) chatDb - copyFile (dir archiveAgentDbFile) agentDb + fs@StorageFiles {chatStore, agentStore, filesPath} <- storageFiles + liftIO $ closeSQLiteStore `withStores` fs + backup `withDBs` fs + copyFile (dir archiveChatDbFile) $ dbFilePath chatStore + copyFile (dir archiveAgentDbFile) $ dbFilePath agentStore copyFiles dir filesPath `E.catch` \(e :: E.SomeException) -> pure [AEImport . ChatError . CEException $ show e] where @@ -94,53 +94,60 @@ copyDirectoryFiles fromDir toDir = do deleteStorage :: ChatMonad m => m () deleteStorage = do - StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - removeFile chatDb - removeFile agentDb - mapM_ removePathForcibly filesPath - tmpPath <- readTVarIO =<< asks tempDirectory - mapM_ removePathForcibly tmpPath + fs <- storageFiles + liftIO $ closeSQLiteStore `withStores` fs + remove `withDBs` fs + mapM_ removeDir $ filesPath fs + mapM_ removeDir =<< chatReadVar tempDirectory + where + remove f = whenM (doesFileExist f) $ removeFile f + removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d data StorageFiles = StorageFiles - { chatDb :: FilePath, - chatEncrypted :: TVar Bool, - agentDb :: FilePath, - agentEncrypted :: TVar Bool, + { chatStore :: SQLiteStore, + agentStore :: SQLiteStore, filesPath :: Maybe FilePath } storageFiles :: ChatMonad m => m StorageFiles storageFiles = do ChatController {chatStore, filesFolder, smpAgent} <- ask - let SQLiteStore {dbFilePath = chatDb, dbEncrypted = chatEncrypted} = chatStore - SQLiteStore {dbFilePath = agentDb, dbEncrypted = agentEncrypted} = agentClientStore smpAgent + let agentStore = agentClientStore smpAgent filesPath <- readTVarIO filesFolder - pure StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted, filesPath} + pure StorageFiles {chatStore, agentStore, filesPath} sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m () sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} = when (key /= key') $ do - fs@StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted} <- storageFiles - checkFile `with` fs - backup `with` fs - (export chatDb chatEncrypted >> export agentDb agentEncrypted) - `catchChatError` \e -> (restore `with` fs) >> throwError e + fs <- storageFiles + checkFile `withDBs` fs + backup `withDBs` fs + checkEncryption `withStores` fs + removeExported `withDBs` fs + export `withDBs` fs + -- closing after encryption prevents closing in case wrong encryption key was passed + liftIO $ closeSQLiteStore `withStores` fs + (moveExported `withStores` fs) + `catchChatError` \e -> (restore `withDBs` fs) >> throwError e where - action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f - export f dbEnc = do - enc <- readTVarIO dbEnc + checkEncryption SQLiteStore {dbEncrypted} = do + enc <- readTVarIO dbEncrypted when (enc && null key) $ throwDBError DBErrorEncrypted when (not enc && not (null key)) $ throwDBError DBErrorPlaintext - withDB (`SQL.exec` exportSQL) DBErrorExport - renameFile (f <> ".exported") f - withDB (`SQL.exec` testSQL) DBErrorOpen - atomically $ writeTVar dbEnc $ not (null key') + exported = (<> ".exported") + removeExported f = whenM (doesFileExist $ exported f) $ removeFile (exported f) + moveExported SQLiteStore {dbFilePath = f, dbEncrypted} = do + renameFile (exported f) f + atomically $ writeTVar dbEncrypted $ not (null key') + export f = do + withDB f (`SQL.exec` exportSQL) DBErrorExport + withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen where - withDB a err = - liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing) + withDB f' a err = + liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing) `catch` checkSQLError `catch` (\(e :: SomeException) -> sqliteError' e) >>= mapM_ (throwDBError . err) @@ -162,7 +169,12 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D keySQL key' <> [ "PRAGMA foreign_keys = ON;", "PRAGMA secure_delete = ON;", - "PRAGMA auto_vacuum = FULL;", "SELECT count(*) FROM sqlite_master;" ] keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] + +withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b +action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore) + +withStores :: Monad m => (SQLiteStore -> m b) -> StorageFiles -> m b +action `withStores` StorageFiles {chatStore, agentStore} = action chatStore >> action agentStore diff --git a/stack.yaml b/stack.yaml index 0840970e49..bce5dd3a68 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + commit: ec1b72cb8013a65a5d9783104a47ae44f5730089 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher From 6c0d1b5f153266cd62b00e709a8be1b6c3e7f60a Mon Sep 17 00:00:00 2001 From: IC Rainbow Date: Fri, 29 Sep 2023 16:53:05 +0300 Subject: [PATCH 021/294] Notify about handover errors --- cabal.project | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Remote.hs | 23 +++++++++++------------ src/Simplex/Chat/Remote/Types.hs | 8 +++++++- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/cabal.project b/cabal.project index 7d7339a7fb..af664652db 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 681fa93bf342d7c836fa0ff69b767dcd08526f03 + tag: ec1b72cb8013a65a5d9783104a47ae44f5730089 source-repository-package type: git diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0598bba8f1..690f78ea4f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -913,7 +913,7 @@ data ChatError | ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity} | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} - | ChatErrorRemoteCtrl {remoteControllerError :: RemoteCtrlError} + | ChatErrorRemoteCtrl {remoteCtrlError :: RemoteCtrlError} | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} deriving (Show, Exception, Generic) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 4671f43537..82d2e9e630 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -132,19 +132,18 @@ startRemoteCtrl = uiEvent <- async $ atomically $ readTMVar accepted waitEitherCatchCancel listener uiEvent >>= \case Left _ -> pure () -- discover got cancelled or crashed on some UDP error - Right (Left _) -> pure () -- readTMVar blocked indefinitely (should not happen) - Right (Right remoteCtrlId) -> do + Right (Left _) -> toView . CRChatError Nothing . ChatError $ CEException "Crashed while waiting for remote session confirmation" + Right (Right remoteCtrlId) -> -- got connection confirmation - (source, fingerprint) <- - atomically $ - TM.lookup remoteCtrlId discovered >>= \case - Nothing -> error "Session accepted without getting registered" - Just found -> found <$ writeTVar discovered mempty -- flush unused sources - host <- async $ runRemoteHost remoteCtrlId source fingerprint - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = host, accepted} - _ <- waitCatch host - chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlStopped {remoteCtrlId} + atomically (TM.lookup remoteCtrlId discovered) >>= \case + Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote session accepted without getting discovered first" + Just (source, fingerprint) -> do + atomically $ writeTVar discovered mempty -- flush unused sources + host <- async $ runRemoteHost remoteCtrlId source fingerprint + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = host, accepted} + _ <- waitCatch host + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped {remoteCtrlId} chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = listener, accepted} pure CRRemoteCtrlStarted diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index fa9b3fb359..5902476fc1 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -5,7 +5,7 @@ module Simplex.Chat.Remote.Types where import Control.Concurrent.Async (Async) -import Data.Aeson (ToJSON) +import Data.Aeson (ToJSON (..)) import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) @@ -13,6 +13,7 @@ import GHC.Generics (Generic) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import UnliftIO.STM +import Simplex.Messaging.Encoding.String (strToJEncoding, strToJSON) type RemoteHostId = Int64 @@ -38,6 +39,11 @@ data RemoteCtrl = RemoteCtrl } deriving (Show, Generic, ToJSON) +-- XXX: until fixed in master +instance ToJSON C.KeyHash where + toEncoding = strToJEncoding + toJSON = strToJSON + data RemoteHostSession = RemoteHostSession { -- | Path for local resources to be synchronized with host storePath :: FilePath, From 5ce388522e855389d7263cc97590c1fed9122f1f Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 29 Sep 2023 17:50:20 +0300 Subject: [PATCH 022/294] Move toView and withStore* to a common module (#3147) --- src/Simplex/Chat.hs | 33 --------------------- src/Simplex/Chat/Controller.hs | 53 +++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 10f925471f..2a568d628f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -19,7 +19,6 @@ module Simplex.Chat where import Control.Applicative (optional, (<|>)) import Control.Concurrent.STM (retry) -import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad import Control.Monad.Except @@ -356,11 +355,6 @@ execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processCha parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -toView :: ChatMonad' m => ChatResponse -> m () -toView event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, event) - processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser @@ -5346,33 +5340,6 @@ withAgent action = >>= runExceptT . action >>= liftEither . first (`ChatErrorAgent` Nothing) -withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a -withStore' action = withStore $ liftIO . action - -withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a -withStore = withStoreCtx Nothing - -withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a -withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action - -withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a -withStoreCtx ctx_ action = do - ChatController {chatStore} <- ask - liftEitherError ChatErrorStore $ case ctx_ of - Nothing -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" - -- uncomment to debug store performance - -- Just ctx -> do - -- t1 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx - -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") - -- t2 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) - -- pure r - Just _ -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" - where - handleInternal :: String -> E.SomeException -> IO (Either StoreError a) - handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr - chatCommandP :: Parser ChatCommand chatCommandP = choice diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 122a4be3f3..15c06cba94 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -46,7 +46,7 @@ import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol -import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) @@ -54,8 +54,9 @@ import Simplex.Messaging.Agent.Client (AgentLocks, ProtocolTestFailure) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -66,7 +67,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors, (<$$>)) +import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import System.IO (Handle) import System.Mem.Weak (Weak) @@ -969,6 +970,15 @@ instance ToJSON SQLiteError where throwDBError :: ChatMonad m => DatabaseError -> m () throwDBError = throwError . ChatErrorDatabase +data ArchiveError + = AEImport {chatError :: ChatError} + | AEImportFile {file :: String, chatError :: ChatError} + deriving (Show, Exception, Generic) + +instance ToJSON ArchiveError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" + type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) @@ -1008,11 +1018,34 @@ unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset) where unset a' = if a == a' then ActiveNone else a' -data ArchiveError - = AEImport {chatError :: ChatError} - | AEImportFile {file :: String, chatError :: ChatError} - deriving (Show, Exception, Generic) +toView :: ChatMonad' m => ChatResponse -> m () +toView event = do + q <- asks outputQ + atomically $ writeTBQueue q (Nothing, event) -instance ToJSON ArchiveError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" +withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a +withStore' action = withStore $ liftIO . action + +withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a +withStore = withStoreCtx Nothing + +withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a +withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action + +withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a +withStoreCtx ctx_ action = do + ChatController {chatStore} <- ask + liftEitherError ChatErrorStore $ case ctx_ of + Nothing -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" + -- uncomment to debug store performance + -- Just ctx -> do + -- t1 <- liftIO getCurrentTime + -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx + -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") + -- t2 <- liftIO getCurrentTime + -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) + -- pure r + Just _ -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" + where + handleInternal :: String -> SomeException -> IO (Either StoreError a) + handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr From d72c9a6de05d5aa4ea6c379a4139458541c0bc20 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 1 Oct 2023 19:12:17 +0800 Subject: [PATCH 023/294] desktop: ability to always show terminal view (#3074) * desktop: ability to always show terminal view * only show toggle with dev tools enabled --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/SimpleXAPI.kt | 2 ++ .../chat/simplex/common/views/TerminalView.kt | 13 ++++++++++++- .../common/views/usersettings/DeveloperView.kt | 11 +++++++++++ .../common/views/usersettings/SettingsView.kt | 9 +++++++++ .../commonMain/resources/MR/base/strings.xml | 1 + .../resources/MR/images/ic_engineering.svg | 1 + .../kotlin/chat/simplex/common/DesktopApp.kt | 17 ++++++++++++++++- 7 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 4bb06afd8b..43043d65fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -106,6 +106,7 @@ class AppPreferences { val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false) + val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050") private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) @@ -265,6 +266,7 @@ class AppPreferences { private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" + private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible" private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy" private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort" private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index e471341669..c4f2a2cbde 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -123,7 +123,18 @@ fun TerminalLog(terminalItems: List) { DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } } - val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } } + val reversedTerminalItems by remember { + derivedStateOf { + // Such logic prevents concurrent modification + val res = ArrayList() + var i = 0 + while (i < terminalItems.size) { + res.add(terminalItems[i]) + i++ + } + res.asReversed() + } + } val clipboard = LocalClipboardManager.current LazyColumn(state = listState, reverseLayout = true) { items(reversedTerminalItems) { item -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index 68aa1fff9e..a6ac8c14ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -34,6 +34,17 @@ fun DeveloperView( ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential), showCustomModal { it, close -> TerminalView(it, close) })} SettingsPreferenceItem(painterResource(MR.images.ic_drive_folder_upload), stringResource(MR.strings.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades) SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools) + if (appPlatform.isDesktop && devTools.value) { + TerminalAlwaysVisibleItem(m.controller.appPrefs.terminalAlwaysVisible) { checked -> + if (checked) { + withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential)) { + m.controller.appPrefs.terminalAlwaysVisible.set(true) + } + } else { + m.controller.appPrefs.terminalAlwaysVisible.set(false) + } + } + } } SectionTextFooter( generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 8969e48b2c..eebe7b3f49 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -322,6 +322,15 @@ fun ChatLockItem( } } +@Composable fun TerminalAlwaysVisibleItem(pref: SharedPreference, onChange: (Boolean) -> Unit) { + SettingsActionItemWithContent(painterResource(MR.images.ic_engineering), stringResource(MR.strings.terminal_always_visible), extraPadding = false) { + DefaultSwitch( + checked = remember { pref.state }.value, + onCheckedChange = onChange, + ) + } +} + @Composable fun InstallTerminalAppItem(uriHandler: UriHandler) { SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) { Icon( diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 26e9948d6c..d57211f4ac 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1056,6 +1056,7 @@ Database downgrade Incompatible database version Confirm database upgrades + Show console in new window Invalid migration confirmation Upgrade and open chat Downgrade and open chat diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg new file mode 100644 index 0000000000..0848537041 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 62a4d9e214..6b81209d4c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -14,10 +14,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatModel -import chat.simplex.common.platform.defaultLocale import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.FileDialogChooser +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener @@ -118,6 +121,18 @@ fun showApp() = application { } } } + // Reload all strings in all @Composable's after language change at runtime + if (remember { ChatController.appPrefs.terminalAlwaysVisible.state }.value && remember { ChatController.appPrefs.appLanguage.state }.value != "") { + var hiddenUntilRestart by remember { mutableStateOf(false) } + if (!hiddenUntilRestart) { + val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH, height = 768.dp) + Window(state = cWindowState, onCloseRequest = ::exitApplication, title = stringResource(MR.strings.chat_console)) { + SimpleXTheme { + TerminalView(ChatModel) { hiddenUntilRestart = true } + } + } + } + } } class SimplexWindowState { From 968d8e9c343b881111f06331d4d251ce75e7bdb9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 1 Oct 2023 13:19:32 +0100 Subject: [PATCH 024/294] core: 5.4.0.0 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 406f9aabaa..907a2a0686 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.3.1.0 +version: 5.4.0.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index dc3a23adc0..ac641f841e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.3.1.0 +version: 5.4.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 695d47da2db6b793e1fe7866733825221c9f4f18 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 1 Oct 2023 20:33:15 +0800 Subject: [PATCH 025/294] desktop: Windows build (#3143) * desktop: Windows build * temp * temp * new way of libs loading * new way of libs loading * Revert "new way of libs loading" This reverts commit 8632f8a8f74ae5b1fd1a3aad8662734a96510a01. * made VLC working on Windows * unused lib * scripts * updated script * fix path * fix lib loading * fix lib loading * packaging options * different file manager implementation on Windows --------- Co-authored-by: Avently Co-authored-by: avently Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../src/commonMain/cpp/desktop/CMakeLists.txt | 13 +++++--- .../common/platform/Platform.desktop.kt | 1 + .../common/platform/RecAndPlay.desktop.kt | 6 ++-- .../views/helpers/DefaultDialog.desktop.kt | 8 ++--- .../common/views/helpers/Utils.desktop.kt | 2 +- apps/multiplatform/desktop/build.gradle.kts | 13 ++++---- .../kotlin/chat/simplex/desktop/Main.kt | 30 +++++++++++++++++-- scripts/android/download-libs.sh | 2 +- scripts/desktop/build-lib-windows.sh | 22 ++++++++++++++ scripts/desktop/download-lib-windows.sh | 27 +++++++++++++++++ scripts/desktop/prepare-vlc-windows.sh | 25 ++++++++++++++++ 11 files changed, 127 insertions(+), 22 deletions(-) create mode 100755 scripts/desktop/build-lib-windows.sh create mode 100644 scripts/desktop/download-lib-windows.sh create mode 100644 scripts/desktop/prepare-vlc-windows.sh diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt index 09ef6fd533..eb4794dd6a 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt @@ -31,9 +31,9 @@ else() set(CMAKE_BUILD_RPATH "@loader_path") endif() -if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64") +if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "AMD64") set(OS_LIB_ARCH "x86_64") -elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64") +elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "ARM64") set(OS_LIB_ARCH "aarch64") else() set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}") @@ -55,8 +55,13 @@ add_library( # Sets the name of the library. add_library( simplex SHARED IMPORTED ) # Lib has different name because of version, find it -FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSsimplex-chat-*.${OS_LIB_EXT}) -set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB}) +FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT}) + +if(WIN32) + set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB}) +else() + set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB}) +endif() # Specifies libraries CMake should link to your target library. You diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index 2121b9cfdf..cb4e3acdb4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -16,6 +16,7 @@ enum class DesktopPlatform(val libPath: String, val libExtension: String, val co MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath); fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64 + fun isWindows() = this == WINDOWS_X86_64 fun isMac() = this == MAC_X86_64 || this == MAC_AARCH64 } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index ed8efcd57f..4439680c66 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -54,9 +54,9 @@ actual object AudioPlayer: AudioPlayerInterface { if (fileSource.cryptoArgs != null) { val tmpFile = fileSource.createTmpFileIfNeeded() decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath) - player.media().prepare("file://${tmpFile.absolutePath}") + player.media().prepare(tmpFile.toURI().toString().replaceFirst("file:", "file://")) } else { - player.media().prepare("file://$absoluteFilePath") + player.media().prepare(File(absoluteFilePath).toURI().toString().replaceFirst("file:", "file://")) } }.onFailure { Log.e(TAG, it.stackTraceToString()) @@ -171,7 +171,7 @@ actual object AudioPlayer: AudioPlayerInterface { var res: Int? = null try { val helperPlayer = AudioPlayerComponent().mediaPlayer() - helperPlayer.media().startPaused("file://$unencryptedFilePath") + helperPlayer.media().startPaused(File(unencryptedFilePath).toURI().toString().replaceFirst("file:", "file://")) res = helperPlayer.duration helperPlayer.stop() helperPlayer.release() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt index 20675dc74e..79fcda7a50 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -10,12 +10,12 @@ import androidx.compose.ui.input.key.* import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.DialogParams +import chat.simplex.common.platform.desktopPlatform import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.awt.FileDialog import java.io.File -import java.util.* import javax.swing.JFileChooser import javax.swing.filechooser.FileFilter import javax.swing.filechooser.FileNameExtensionFilter @@ -53,7 +53,7 @@ fun FrameWindowScope.FileDialogChooser( params: DialogParams, onResult: (result: List) -> Unit ) { - if (isLinux()) { + if (desktopPlatform.isLinux() || desktopPlatform.isWindows()) { FileDialogChooserMultiple(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, params.fileFilterDescription, onResult) } else { FileDialogAwt(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, onResult) @@ -121,7 +121,7 @@ fun FrameWindowScope.FileDialogChooserMultiple( } /* -* Has graphic glitches on many Linux distributions, so use only on non-Linux systems +* Has graphic glitches on many Linux distributions, so use only on non-Linux systems. Also file filter doesn't work on Windows * */ @Composable private fun FrameWindowScope.FileDialogAwt( @@ -159,5 +159,3 @@ private fun FrameWindowScope.FileDialogAwt( }, dispose = FileDialog::dispose ) - -fun isLinux(): Boolean = System.getProperty("os.name", "generic").lowercase(Locale.ENGLISH) == "linux" diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 21b2cfa6e0..2a9042c433 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -88,7 +88,7 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat } actual fun getAppFileUri(fileName: String): URI = - URI("file:" + appFilesDir.absolutePath + File.separator + fileName) + URI(appFilesDir.toURI().toString() + "/" + fileName) actual fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index a4e8207a15..bd9cb68d89 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -63,8 +63,8 @@ compose { windows { packageName = "SimpleX" iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.ico")) - console = true - perUserInstall = true + console = false + perUserInstall = false dirChooser = true } macOS { @@ -119,9 +119,9 @@ cmake { /*machines.customMachines.register("linux-aarch64") { toolchainFile.set(project.file("$cppPath/toolchains/aarch64-linux-gnu-gcc.cmake")) }*/ - machines.customMachines.register("win-amd64") { + /*machines.customMachines.register("win-amd64") { toolchainFile.set(project.file("$cppPath/toolchains/x86_64-windows-mingw32-gcc.cmake")) - } + }*/ if (machines.host.name == "mac-amd64") { machines.customMachines.register("mac-amd64") { toolchainFile.set(project.file("$cppPath/toolchains/x86_64-mac-apple-darwin-gcc.cmake")) @@ -139,6 +139,9 @@ cmake { val main by creating { cmakeLists.set(file("$cppPath/desktop/CMakeLists.txt")) targetMachines.addAll(compileMachineTargets.toSet()) + if (machines.host.name.contains("win")) { + cmakeArgs.add("-G MinGW Makefiles") + } } } } @@ -191,7 +194,7 @@ afterEvaluate { copyIfNeeded(destinationDir, copyDetails) } copy { - from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") + from("${project(":desktop").buildDir}/cmake/main/windows-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") into("src/jvmMain/resources/libs/windows-x86_64") include("*.dll") eachFile { diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index 72c41a665b..a0be877329 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -18,13 +18,15 @@ fun main() { @Suppress("UnsafeDynamicallyLoadedCode") private fun initHaskell() { - val libApp = "libapp-lib.${desktopPlatform.libExtension}" val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs") copyResources(desktopPlatform.libPath, libsTmpDir.toPath()) - System.load(File(libsTmpDir, libApp).absolutePath) - vlcDir.deleteRecursively() Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING) + if (desktopPlatform == DesktopPlatform.WINDOWS_X86_64) { + windowsLoadRequiredLibs(libsTmpDir) + } else { + System.load(File(libsTmpDir, "libapp-lib.${desktopPlatform.libExtension}").absolutePath) + } // No picture without preloading it, only sound. However, with libs from AppImage it works without preloading //val libXcb = "libvlc_xcb_events.so.0.0.0" //System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath) @@ -55,3 +57,25 @@ private fun copyResources(from: String, to: Path) { } }) } + +private fun windowsLoadRequiredLibs(libsTmpDir: File) { + val mainLibs = arrayOf( + "libcrypto-3-x64.dll", + "libffi-8.dll", + "libgmp-10.dll", + "libsimplex.dll", + "libapp-lib.dll" + ) + mainLibs.forEach { + System.load(File(libsTmpDir, it).absolutePath) + } + val vlcLibs = arrayOf( + "libvlccore.dll", + "libvlc.dll", + "axvlc.dll", + "npvlc.dll" + ) + vlcLibs.forEach { + System.load(File(vlcDir, it).absolutePath) + } +} diff --git a/scripts/android/download-libs.sh b/scripts/android/download-libs.sh index f67cc397a7..4702f03600 100755 --- a/scripts/android/download-libs.sh +++ b/scripts/android/download-libs.sh @@ -7,7 +7,7 @@ function readlink() { } if [ -z "${1}" ]; then - echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download_libs.sh https://something.com/job/something/{master,stable}" + echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-libs.sh https://something.com/job/something/{master,stable}" exit 1 fi diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh new file mode 100755 index 0000000000..658324baa8 --- /dev/null +++ b/scripts/desktop/build-lib-windows.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" + +OS=windows +ARCH=`uname -a | rev | cut -d' ' -f2 | rev` +JOB_REPO=${1:-$SIMPLEX_CI_REPO_URL} + +cd $root_dir + +rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/ +rm -rf apps/multiplatform/desktop/build/cmake + +mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +scripts/desktop/download-lib-windows.sh $JOB_REPO +scripts/desktop/prepare-vlc-windows.sh diff --git a/scripts/desktop/download-lib-windows.sh b/scripts/desktop/download-lib-windows.sh new file mode 100644 index 0000000000..14439274c0 --- /dev/null +++ b/scripts/desktop/download-lib-windows.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} + +if [ -z "${1}" ]; then + echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-lib-windows.sh https://something.com/job/something/{windows,windows-8107}" + exit 1 +fi + +job_repo=$1 +arch=x86_64 +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" +output_dir="$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-$arch/" + +mkdir -p "$output_dir"/deps 2> /dev/null + +curl --location -o libsimplex.zip $job_repo/$arch-linux.$arch-windows:lib:simplex-chat/latest/download/1 && \ +$WINDIR\\System32\\tar.exe -xf libsimplex.zip && \ +mv libsimplex.dll "$output_dir" && \ +mv libcrypto*.dll "$output_dir/deps" && \ +mv libffi*.dll "$output_dir/deps" && \ +mv libgmp*.dll "$output_dir/deps" && \ +rm libsimplex.zip diff --git a/scripts/desktop/prepare-vlc-windows.sh b/scripts/desktop/prepare-vlc-windows.sh new file mode 100644 index 0000000000..bdb492344a --- /dev/null +++ b/scripts/desktop/prepare-vlc-windows.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64/deps/vlc +rm -rf $vlc_dir +mkdir -p $vlc_dir/vlc || exit 0 + +cd /tmp +mkdir tmp 2>/dev/null || true +cd tmp +curl https://irltoolkit.mm.fcix.net/videolan-ftp/vlc/3.0.18/win64/vlc-3.0.18-win64.zip -L -o vlc +$WINDIR\\System32\\tar.exe -xf vlc +cd vlc-* +# Setting the same date as the date that will be on the file after extraction from JAR to make VLC cache checker happy +find plugins | grep ".dll" | xargs touch -m -d "1970-01-01T00:00:00Z" +./vlc-cache-gen plugins +cp *.dll $vlc_dir/ +cp -r -p plugins/ $vlc_dir/vlc/plugins +cd ../../ +rm -rf tmp From 7231201c3c9445d998c8324a96eab7a5c791f03b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 1 Oct 2023 18:31:52 +0100 Subject: [PATCH 026/294] v5.4-beta.0: ios 176, android 156, desktop 12 * desktop: v5.4-beta.0 build 12 * v5.4-beta.0: ios 176, android 156, desktop 12 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 64 +++++++++++----------- apps/multiplatform/gradle.properties | 8 +-- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 20ba15e925..8b45e45f95 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -48,11 +48,6 @@ 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; }; 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; - 5C56251A2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */; }; - 5C56251B2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */; }; - 5C56251C2AC1DE5900A21210 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625172AC1DE5900A21210 /* libgmpxx.a */; }; - 5C56251D2AC1DE5900A21210 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625182AC1DE5900A21210 /* libgmp.a */; }; - 5C56251E2AC1DE5900A21210 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625192AC1DE5900A21210 /* libffi.a */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; }; 5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; }; @@ -119,6 +114,11 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; + 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */; }; + 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739892AC9D168009470A9 /* libgmp.a */; }; + 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398A2AC9D168009470A9 /* libffi.a */; }; + 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398B2AC9D168009470A9 /* libgmpxx.a */; }; + 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; @@ -293,11 +293,6 @@ 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = ""; }; 5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = ""; }; - 5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a"; sourceTree = ""; }; - 5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a"; sourceTree = ""; }; - 5C5625172AC1DE5900A21210 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C5625182AC1DE5900A21210 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C5625192AC1DE5900A21210 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = ""; }; 5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; @@ -400,6 +395,11 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; + 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a"; sourceTree = ""; }; + 5CC739892AC9D168009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CC7398A2AC9D168009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CC7398B2AC9D168009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a"; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; @@ -507,12 +507,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C56251C2AC1DE5900A21210 /* libgmpxx.a in Frameworks */, - 5C56251B2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a in Frameworks */, - 5C56251A2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a in Frameworks */, - 5C56251E2AC1DE5900A21210 /* libffi.a in Frameworks */, - 5C56251D2AC1DE5900A21210 /* libgmp.a in Frameworks */, + 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */, + 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */, + 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */, + 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -574,11 +574,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C5625192AC1DE5900A21210 /* libffi.a */, - 5C5625182AC1DE5900A21210 /* libgmp.a */, - 5C5625172AC1DE5900A21210 /* libgmpxx.a */, - 5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */, - 5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */, + 5CC7398A2AC9D168009470A9 /* libffi.a */, + 5CC739892AC9D168009470A9 /* libgmp.a */, + 5CC7398B2AC9D168009470A9 /* libgmpxx.a */, + 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */, + 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */, ); path = Libraries; sourceTree = ""; @@ -1486,7 +1486,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 176; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1507,7 +1507,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1528,7 +1528,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 176; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1549,7 +1549,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1608,7 +1608,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 176; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1621,7 +1621,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1640,7 +1640,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 176; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1653,7 +1653,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1672,7 +1672,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 176; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1696,7 +1696,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1718,7 +1718,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 176; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1742,7 +1742,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 0d047a7917..e37dbca8eb 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.3.1 -android.version_code=154 +android.version_name=5.4-beta.0 +android.version_code=156 -desktop.version_name=5.3.1 -desktop.version_code=11 +desktop.version_name=5.4-beta.0 +desktop.version_code=12 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From 157ea59ebb9320f0b69132f24d033e3877b4951b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 1 Oct 2023 18:53:58 +0100 Subject: [PATCH 027/294] docs: update downloads page --- docs/DOWNLOADS.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 810fd6f4c1..64b76e7fec 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -1,13 +1,15 @@ --- title: Download SimpleX apps permalink: /downloads/index.html -revision: 20.09.2023 +revision: 01.10.2023 --- -| Updated 20.09.2023 | Languages: EN | +| Updated 01.10.2023 | Languages: EN | # Download SimpleX apps -The latest version is v5.3.1. +The latest stable version is v5.3.1. + +You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). - [desktop](#desktop-app) - [mobile](#mobile-apps) @@ -23,7 +25,7 @@ Using the same profile as on mobile device is not yet supported – you need to **Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). -**Windows**: coming soon. +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.0/simplex-desktop-windows-x86-64.msi) (BETA). ## Mobile apps From 8545a1e8f93a396dc05ab9722944cc501bfc577c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 1 Oct 2023 20:46:30 +0100 Subject: [PATCH 028/294] ci: make docs update rebuild website --- .github/workflows/web.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 4525f3c5dc..039c136464 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -9,6 +9,7 @@ on: - website/** - images/** - blog/** + - docs/** - .github/workflows/web.yml jobs: From d4cbef1ba1728456e6825c5866effc0eff8383ab Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 2 Oct 2023 16:29:13 +0400 Subject: [PATCH 029/294] core: notify about contact deletion only if contact is ready, catch errors (#3160) --- src/Simplex/Chat.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2a568d628f..5b56c0ac84 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -897,7 +897,8 @@ processChatCommand = \case filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withChatLock "deleteChat direct" . procCmd $ do deleteFilesAndConns user filesInfo - when (contactActive ct && notify) . void $ sendDirectContactMessage ct XDirectDel + when (isReady ct && contactActive ct && notify) $ + void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) deleteAgentConnectionsAsync user contactConnIds -- functions below are called in separate transactions to prevent crashes on android From 0d93dab6927837faa412f557fb082575910dedb6 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 2 Oct 2023 22:46:30 +0800 Subject: [PATCH 030/294] android, desktop: added useful logs (#3163) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 7 +++++++ .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 10 +++++++++- .../common/views/chatlist/ChatListNavLinkView.kt | 2 ++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 0eb02515b5..8687ac390a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -224,6 +224,7 @@ object ChatModel { } // add to current chat if (chatId.value == cInfo.id) { + Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") withContext(Dispatchers.Main) { // Prevent situation when chat item already in the list received from backend if (chatItems.none { it.id == cItem.id }) { @@ -231,6 +232,7 @@ object ChatModel { chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem) } else { chatItems.add(cItem) + Log.d(TAG, "TODOCHAT: addChatItem: added to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") } } } @@ -259,13 +261,16 @@ object ChatModel { } // update current chat return if (chatId.value == cInfo.id) { + Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") withContext(Dispatchers.Main) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem + Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") false } else { chatItems.add(cItem) + Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") true } } @@ -374,6 +379,7 @@ object ChatModel { var markedRead = 0 if (chatId.value == cInfo.id) { var i = 0 + Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marking read ${cInfo.id}, current chatId ${chatId.value}, size was ${chatItems.size}") while (i < chatItems.count()) { val item = chatItems[i] if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) { @@ -388,6 +394,7 @@ object ChatModel { } i += 1 } + Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marked read ${cInfo.id}, current chatId ${chatId.value}, size now ${chatItems.size}") } return markedRead } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index b22b2f91c8..ee9e109a7b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -66,11 +66,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: launch { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() + .onEach { Log.d(TAG, "TODOCHAT: chatId: activeChatId ${activeChat.value?.id} == new chatId $it ${activeChat.value?.id == it} ") } .filter { it != null && activeChat.value?.id != it } .collect { chatId -> // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly // Also for situation when chatId changes after clicking in notification, etc activeChat.value = chatModel.getChat(chatId!!) + Log.d(TAG, "TODOCHAT: chatId: activeChatId became ${activeChat.value?.id}") markUnreadChatAsRead(activeChat, chatModel) } } @@ -89,9 +91,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } .distinctUntilChanged() + .onEach { Log.d(TAG, "TODOCHAT: chats: activeChatId ${activeChat.value?.id} == new chatId ${it?.id} ${activeChat.value?.id == it?.id} ") } // Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions .filter { it != null && it?.chatInfo != activeChat.value?.chatInfo } - .collect { activeChat.value = it } + .collect { + activeChat.value = it + Log.d(TAG, "TODOCHAT: chats: activeChatId became ${activeChat.value?.id}") + } } } val view = LocalMultiplatformView() @@ -218,7 +224,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val firstId = chatModel.chatItems.firstOrNull()?.id if (c != null && firstId != null) { withApi { + Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}") apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value) + Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}") } } }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 7a0fc12786..4d95431a37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -122,9 +122,11 @@ suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { } suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { + Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) if (chat != null) { openChat(chat, chatModel) + Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") } } From fdf3da73aadafe1e94313da35fbc6088e6a616f6 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 3 Oct 2023 00:23:38 +0800 Subject: [PATCH 031/294] desktop: making chat list item to have a hover effect (#3162) * desktop: making chat list item to have a hover effect * changes * fix --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chatlist/ChatListNavLinkView.android.kt | 39 ++++++++++++ .../views/chatlist/ChatListNavLinkView.kt | 56 ++++++++--------- .../chatlist/ChatListNavLinkView.desktop.kt | 60 +++++++++++++++++++ 3 files changed, 124 insertions(+), 31 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt new file mode 100644 index 0000000000..3f33913e54 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt @@ -0,0 +1,39 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.Divider +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.helpers.* + +@Composable +actual fun ChatListNavLinkLayout( + chatLinkPreview: @Composable () -> Unit, + click: () -> Unit, + dropdownMenuItems: (@Composable () -> Unit)?, + showMenu: MutableState, + stopped: Boolean, + selectedChat: State +) { + var modifier = Modifier.fillMaxWidth() + if (!stopped) modifier = modifier + .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true } + Box(modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp), + verticalAlignment = Alignment.Top + ) { + chatLinkPreview() + } + if (dropdownMenuItems != null) { + DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems) + } + } + Divider(Modifier.padding(horizontal = 8.dp)) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 4d95431a37..41c94f21d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -1,7 +1,6 @@ package chat.simplex.common.views.chatlist import SectionItemView -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -14,6 +13,10 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -44,6 +47,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { showMenu.value = false delay(500L) } + val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } } val showChatPreviews = chatModel.showChatPreviews.value when (chat.chatInfo) { is ChatInfo.Direct -> { @@ -53,7 +57,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, - stopped + stopped, + selectedChat ) } is ChatInfo.Group -> @@ -62,7 +67,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, showMenu, - stopped + stopped, + selectedChat ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( @@ -70,7 +76,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, showMenu, - stopped + stopped, + selectedChat ) is ChatInfo.ContactConnection -> ChatListNavLinkLayout( @@ -84,7 +91,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { }, dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) }, showMenu, - stopped + stopped, + selectedChat ) is ChatInfo.InvalidJSON -> ChatListNavLinkLayout( @@ -97,7 +105,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { }, dropdownMenuItems = null, showMenu, - stopped + stopped, + selectedChat ) } } @@ -630,32 +639,14 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo } @Composable -fun ChatListNavLinkLayout( +expect fun ChatListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, click: () -> Unit, dropdownMenuItems: (@Composable () -> Unit)?, showMenu: MutableState, - stopped: Boolean -) { - var modifier = Modifier.fillMaxWidth() - if (!stopped) modifier = modifier - .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) - .onRightClick { showMenu.value = true } - Box(modifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp), - verticalAlignment = Alignment.Top - ) { - chatLinkPreview() - } - if (dropdownMenuItems != null) { - DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems) - } - } - Divider(Modifier.padding(horizontal = 8.dp)) -} + stopped: Boolean, + selectedChat: State +) @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -692,7 +683,8 @@ fun PreviewChatListNavLinkDirect() { click = {}, dropdownMenuItems = null, showMenu = remember { mutableStateOf(false) }, - stopped = false + stopped = false, + selectedChat = remember { mutableStateOf(false) } ) } } @@ -732,7 +724,8 @@ fun PreviewChatListNavLinkGroup() { click = {}, dropdownMenuItems = null, showMenu = remember { mutableStateOf(false) }, - stopped = false + stopped = false, + selectedChat = remember { mutableStateOf(false) } ) } } @@ -752,7 +745,8 @@ fun PreviewChatListNavLinkContactRequest() { click = {}, dropdownMenuItems = null, showMenu = remember { mutableStateOf(false) }, - stopped = false + stopped = false, + selectedChat = remember { mutableStateOf(false) } ) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt new file mode 100644 index 0000000000..0ad69d1c04 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -0,0 +1,60 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.helpers.* + +private object NoIndication : Indication { + private object NoIndicationInstance : IndicationInstance { + override fun ContentDrawScope.drawIndication() { + drawContent() + } + } + @Composable + override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance { + return NoIndicationInstance + } +} + +@Composable +actual fun ChatListNavLinkLayout( + chatLinkPreview: @Composable () -> Unit, + click: () -> Unit, + dropdownMenuItems: (@Composable () -> Unit)?, + showMenu: MutableState, + stopped: Boolean, + selectedChat: State +) { + var modifier = Modifier.fillMaxWidth() + if (!stopped) modifier = modifier + .background(color = if (selectedChat.value) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) + .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true } + CompositionLocalProvider( + LocalIndication provides if (selectedChat.value && !stopped) NoIndication else LocalIndication.current + ) { + Box(modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp), + verticalAlignment = Alignment.Top + ) { + chatLinkPreview() + } + if (dropdownMenuItems != null) { + DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems) + } + } + } + Divider() +} From 77db70139b428e608aaaeac8be88a73cf4974c84 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 3 Oct 2023 00:25:49 +0800 Subject: [PATCH 032/294] windows: shortcut for installator (#3156) Co-authored-by: avently --- apps/multiplatform/desktop/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index bd9cb68d89..3da87850d5 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -66,6 +66,7 @@ compose { console = false perUserInstall = false dirChooser = true + shortcut = true } macOS { packageName = "SimpleX" From da2a94578abdf82f1650680ef9a72d099474a0eb Mon Sep 17 00:00:00 2001 From: zenobit Date: Mon, 2 Oct 2023 22:21:26 +0200 Subject: [PATCH 033/294] typo (#3121) --- ...-v5-3-desktop-app-local-file-encryption-directory-service.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md index 8928e9724d..fc924a8706 100644 --- a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md +++ b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md @@ -96,7 +96,7 @@ Incognito mode was [added a year ago](./20220901-simplex-chat-v3.2-incognito-mod It is now simpler to use - you can decide whether to connect to a contact or join a group using your main profile at a point when you create an invitation link or connect via a link or QR code. -When you are connecting to people your know you usually want to share your main profile, and when connecting to public groups or strangers, you may prefer to use a random profile. +When you are connecting to people you know you usually want to share your main profile, and when connecting to public groups or strangers, you may prefer to use a random profile. ## SimpleX platform From 38be27271f48c71ac5bc5097531f0411bffc8bc3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 2 Oct 2023 21:56:11 +0100 Subject: [PATCH 034/294] core: profile names with spaces (#3105) * core: profile names with spaces * test * more test * update name validation, export C API * refactor * validate name when creating profile in CLI * validate display name in all APIs when it is changed --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 52 +++++++++--- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Mobile.hs | 6 ++ src/Simplex/Chat/View.hs | 143 ++++++++++++++++++--------------- tests/ChatTests/Profiles.hs | 75 ++++++++++++++++- tests/ChatTests/Utils.hs | 2 +- tests/MobileTests.hs | 10 +++ tests/Test.hs | 2 + tests/ValidNames.hs | 27 +++++++ 10 files changed, 237 insertions(+), 82 deletions(-) create mode 100644 tests/ValidNames.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ac641f841e..0b613310f7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -456,6 +456,7 @@ test-suite simplex-chat-test MobileTests ProtocolTests SchemaDump + ValidNames ViewTests WebRTCTests Broadcast.Bot diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5b56c0ac84..5d370ee651 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -32,7 +32,7 @@ import Data.Bifunctor (bimap, first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Char (isSpace, toLower) +import Data.Char import Data.Constraint (Dict (..)) import Data.Either (fromRight, rights) import Data.Fixed (div') @@ -359,6 +359,7 @@ processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser CreateActiveUser NewUser {profile, sameServers, pastTimestamp} -> do + forM_ profile $ \Profile {displayName} -> checkValidName displayName p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile u <- asks currentUser (smp, smpServers) <- chooseServers SPSMP @@ -1457,7 +1458,8 @@ processChatCommand = \case chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg processChatCommand $ APIChatItemReaction chatRef chatItemId add reaction - APINewGroup userId gProfile -> withUserId userId $ \user -> do + APINewGroup userId gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do + checkValidName displayName gVar <- asks idsDrg groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile pure $ CRGroupCreated user groupInfo @@ -1962,9 +1964,10 @@ processChatCommand = \case updateProfile :: User -> Profile -> m ChatResponse updateProfile user p' = updateProfile_ user p' $ withStore $ \db -> updateUserProfile db user p' updateProfile_ :: User -> Profile -> m User -> m ChatResponse - updateProfile_ user@User {profile = p} p' updateUser + updateProfile_ user@User {profile = p@LocalProfile {displayName = n}} p'@Profile {displayName = n'} updateUser | p' == fromLocalProfile p = pure $ CRUserProfileNoChange user | otherwise = do + when (n /= n') $ checkValidName n' -- read contacts before user update to correctly merge preferences -- [incognito] filter out contacts with whom user has incognito connections contacts <- @@ -2006,8 +2009,9 @@ processChatCommand = \case when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> m ChatResponse - runUpdateGroupProfile user (Group g@GroupInfo {groupProfile = p} ms) p' = do + runUpdateGroupProfile user (Group g@GroupInfo {groupProfile = p@GroupProfile {displayName = n}} ms) p'@GroupProfile {displayName = n'} = do assertUserGroupRole g GROwner + when (n /= n') $ checkValidName n' g' <- withStore $ \db -> updateGroupProfile db user g p' (msg, _) <- sendGroupMessage user g' ms (XGrpInfo p') let cd = CDGroupSnd g' @@ -2016,6 +2020,10 @@ processChatCommand = \case toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat g') ci) createGroupFeatureChangedItems user cd CISndGroupFeature g g' pure $ CRGroupUpdated user g g' Nothing + checkValidName :: GroupName -> m () + checkValidName displayName = do + let validName = T.pack $ mkValidName $ T.unpack displayName + when (displayName /= validName) $ throwChatError CEInvalidDisplayName {displayName, validName} assertUserGroupRole :: GroupInfo -> GroupMemberRole -> m () assertUserGroupRole g@GroupInfo {membership} requiredRole = do when (membership.memberRole < requiredRole) $ throwChatError $ CEGroupUserRole g requiredRole @@ -5245,8 +5253,7 @@ getCreateActiveUser st testView = do where loop = do displayName <- getContactName - fullName <- T.pack <$> getWithPrompt "full name (optional)" - withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) Profile {displayName, fullName, image = Nothing, contactLink = Nothing, preferences = Nothing} True) >>= \case + withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} True) >>= \case Left SEDuplicateName -> do putStrLn "chosen display name is already used by another profile on this device, choose another one" loop @@ -5276,10 +5283,13 @@ getCreateActiveUser st testView = do T.unpack $ localDisplayName <> if T.null fullName || localDisplayName == fullName then "" else " (" <> fullName <> ")" getContactName :: IO ContactName getContactName = do - displayName <- getWithPrompt "display name (no spaces)" - if null displayName || isJust (find (== ' ') displayName) - then putStrLn "display name has space(s), choose another one" >> getContactName - else pure $ T.pack displayName + displayName <- getWithPrompt "display name" + let validName = mkValidName displayName + if + | null displayName -> putStrLn "display name can't be empty" >> getContactName + | null validName -> putStrLn "display name is invalid, please choose another" >> getContactName + | displayName /= validName -> putStrLn ("display name is invalid, you could use this one: " <> validName) >> getContactName + | otherwise -> pure $ T.pack displayName getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine @@ -5610,7 +5620,13 @@ chatCommandP = mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal - displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) + displayName = safeDecodeUtf8 <$> (quoted "'\"" <|> takeNameTill isSpace) + where + takeNameTill p = + A.peekChar' >>= \c -> + if refChar c then A.takeTill p else fail "invalid first character in display name" + quoted cs = A.choice [A.char c *> takeNameTill (== c) <* A.char c | c <- cs] + refChar c = c > ' ' && c /= '#' && c /= '@' sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space reactionP = MREmoji <$> (mrEmojiChar <$?> (toEmoji <$> A.anyChar)) @@ -5623,7 +5639,6 @@ chatCommandP = '*' -> head "❤️" '^' -> '🚀' c -> c - refChar c = c > ' ' && c /= '#' && c /= '@' liveMessageP = " live=" *> onOffP <|> pure False sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing receiptSettings = do @@ -5718,3 +5733,16 @@ timeItToView s action = do let diff = diffToMilliseconds $ diffUTCTime t2 t1 toView $ CRTimedAction s diff pure a + +mkValidName :: String -> String +mkValidName = reverse . dropWhile isSpace . fst . foldl' addChar ("", '\NUL') + where + addChar (r, prev) c = if notProhibited && validChar then (c' : r, c') else (r, prev) + where + c' = if isSpace c then ' ' else c + validChar + | prev == '\NUL' || isSpace prev = validFirstChar + | isPunctuation prev = validFirstChar || isSpace c + | otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c + validFirstChar = isLetter c || isNumber c || isSymbol c + notProhibited = c `notElem` ("@#'\"`" :: String) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 15c06cba94..61840b8e84 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -882,6 +882,7 @@ data ChatErrorType | CEEmptyUserPassword {userId :: UserId} | CEUserAlreadyHidden {userId :: UserId} | CEUserNotHidden {userId :: UserId} + | CEInvalidDisplayName {displayName :: Text, validName :: Text} | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 700548bb12..783e38ef3f 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -65,6 +65,8 @@ foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSON foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CString -> IO CString +foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString + foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString @@ -124,6 +126,10 @@ cChatPasswordHash cPwd cSalt = do salt <- B.packCString cSalt newCStringFromBS $ chatPasswordHash pwd salt +-- This function supports utf8 strings +cChatValidName :: CString -> IO CString +cChatValidName cName = newCString . mkValidName =<< peekCString cName + mobileChatOpts :: String -> String -> ChatOpts mobileChatOpts dbFilePrefix dbKey = ChatOpts diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 01bdfba95a..5eb9df3ad2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -15,7 +15,7 @@ import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB -import Data.Char (toUpper) +import Data.Char (isSpace, toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) @@ -224,7 +224,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"] CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"] - CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] + CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g CRGroupDescription u g -> ttyUser u $ viewGroupDescription g @@ -674,10 +674,7 @@ viewContactNotFound cName suspectedMember = ["no contact " <> ttyContact cName <> useMessageMember] where useMessageMember = case suspectedMember of - Just (g, m) -> do - let GroupInfo {localDisplayName = gName} = g - GroupMember {localDisplayName = mName} = m - ", use " <> highlight' ("@#" <> T.unpack gName <> " " <> T.unpack mName <> " ") + Just (g, m) -> ", use " <> highlight ("@#" <> viewGroupName g <> " " <> viewMemberName m <> " ") _ -> "" viewChatCleared :: AChatInfo -> [StyledString] @@ -732,14 +729,14 @@ groupLink_ intro g cReq mRole = (plain . strEncode) cReq, "", "Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c ", - "to show it again: " <> highlight ("/show link #" <> groupName' g), - "to delete it: " <> highlight ("/delete link #" <> groupName' g) <> " (joined members will remain connected to you)" + "to show it again: " <> highlight ("/show link #" <> viewGroupName g), + "to delete it: " <> highlight ("/delete link #" <> viewGroupName g) <> " (joined members will remain connected to you)" ] viewGroupLinkDeleted :: GroupInfo -> [StyledString] viewGroupLinkDeleted g = [ "Group link is deleted - joined members will remain connected.", - "To create a new group link use " <> highlight ("/create link #" <> groupName' g) + "To create a new group link use " <> highlight ("/create link #" <> viewGroupName g) ] viewSentInvitation :: Maybe Profile -> Bool -> [StyledString] @@ -756,20 +753,20 @@ viewSentInvitation incognitoProfile testView = viewReceivedContactRequest :: ContactName -> Profile -> [StyledString] viewReceivedContactRequest c Profile {fullName} = [ ttyFullName c fullName <> " wants to connect to you!", - "to accept: " <> highlight ("/ac " <> c), - "to reject: " <> highlight ("/rc " <> c) <> " (the sender will NOT be notified)" + "to accept: " <> highlight ("/ac " <> viewName c), + "to reject: " <> highlight ("/rc " <> viewName c) <> " (the sender will NOT be notified)" ] viewGroupCreated :: GroupInfo -> [StyledString] -viewGroupCreated g@GroupInfo {localDisplayName = n} = +viewGroupCreated g = [ "group " <> ttyFullGroup g <> " is created", - "to add members use " <> highlight ("/a " <> n <> " ") <> " or " <> highlight ("/create link #" <> n) + "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) ] viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] -viewCannotResendInvitation GroupInfo {localDisplayName = gn} c = - [ ttyContact c <> " is already invited to group " <> ttyGroup gn, - "to re-send invitation: " <> highlight ("/rm " <> gn <> " " <> c) <> ", " <> highlight ("/a " <> gn <> " " <> c) +viewCannotResendInvitation g c = + [ ttyContact c <> " is already invited to group " <> ttyGroup' g, + "to re-send invitation: " <> highlight ("/rm " <> viewGroupName g <> " " <> c) <> ", " <> highlight ("/a " <> viewGroupName g <> " " <> viewName c) ] viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] @@ -790,11 +787,11 @@ viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [Style viewReceivedGroupInvitation g c role = ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role) : case incognitoMembershipProfile g of - Just mp -> ["use " <> highlight ("/j " <> groupName' g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)] - Nothing -> ["use " <> highlight ("/j " <> groupName' g) <> " to accept"] + Just mp -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)] + Nothing -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to accept"] groupPreserved :: GroupInfo -> [StyledString] -groupPreserved g = ["use " <> highlight ("/d #" <> groupName' g) <> " to delete the group"] +groupPreserved g = ["use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the group"] connectedMember :: GroupMember -> StyledString connectedMember m = case memberCategory m of @@ -845,7 +842,7 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt _ -> "" viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] -viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView = +viewContactConnected ct userIncognitoProfile testView = case userIncognitoProfile of Just profile -> if testView @@ -854,7 +851,7 @@ viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView where message = [ ttyFullContact ct <> ": contact is connected, your incognito profile for this contact is " <> incognitoProfile' profile, - "use " <> highlight ("/i " <> localDisplayName) <> " to print out this incognito profile again" + "use " <> highlight ("/i " <> viewContactName ct) <> " to print out this incognito profile again" ] Nothing -> [ttyFullContact ct <> ": contact is connected"] @@ -865,10 +862,10 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs where ldn_ :: GroupInfo -> Text ldn_ g = T.toLower g.localDisplayName - groupSS (g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership, chatSettings}, GroupSummary {currentMembers}) = + groupSS (g@GroupInfo {membership, chatSettings}, GroupSummary {currentMembers}) = case memberStatus membership of GSMemInvited -> groupInvitation' g - s -> membershipIncognito g <> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s where viewMemberStatus = \case GSMemRemoved -> delete "you are removed" @@ -876,18 +873,18 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs GSMemGroupDeleted -> delete "group deleted" _ | enableNtfs chatSettings -> " (" <> memberCount <> ")" - | otherwise -> " (" <> memberCount <> ", muted, you can " <> highlight ("/unmute #" <> ldn) <> ")" - delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> ldn) <> ")" + | otherwise -> " (" <> memberCount <> ", muted, you can " <> highlight ("/unmute #" <> viewGroupName g) <> ")" + delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" groupInvitation' :: GroupInfo -> StyledString groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = - highlight ("#" <> ldn) + highlight ("#" <> viewName ldn) <> optFullName ldn fullName <> " - you are invited (" - <> highlight ("/j " <> ldn) + <> highlight ("/j " <> viewName ldn) <> joinText - <> highlight ("/d #" <> ldn) + <> highlight ("/d #" <> viewName ldn) <> " to delete invitation)" where joinText = case incognitoMembershipProfile g of @@ -895,21 +892,21 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Nothing -> " to join, " viewContactsMerged :: Contact -> Contact -> [StyledString] -viewContactsMerged _into@Contact {localDisplayName = c1} _merged@Contact {localDisplayName = c2} = - [ "contact " <> ttyContact c2 <> " is merged into " <> ttyContact c1, - "use " <> ttyToContact c1 <> highlight' "" <> " to send messages" +viewContactsMerged c1 c2 = + [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, + "use " <> ttyToContact' c1 <> highlight' "" <> " to send messages" ] viewUserProfile :: Profile -> [StyledString] viewUserProfile Profile {displayName, fullName} = [ "user profile: " <> ttyFullName displayName fullName, - "use " <> highlight' "/p []" <> " to change it", + "use " <> highlight' "/p " <> " to change it", "(the updated profile will be sent to all your contacts)" ] viewUserPrivacy :: User -> User -> [StyledString] viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', showNtfs, viewPwdHash} = - [ (if userId == userId' then "current " else "") <> "user " <> plain n' <> ":", + [ plain $ (if userId == userId' then "current " else "") <> "user " <> viewName n' <> ":", "messages are " <> if showNtfs then "shown" else "hidden (use /tail to view)", "profile is " <> if isJust viewPwdHash then "hidden" else "visible" ] @@ -1055,18 +1052,18 @@ viewGroupMemberSwitch g m (SwitchProgress qd phase _) = case qd of QDSnd -> [ttyGroup' g <> ": " <> ttyMember m <> " " <> viewSwitchPhase phase <> " for you"] viewContactRatchetSync :: Contact -> RatchetSyncProgress -> [StyledString] -viewContactRatchetSync ct@Contact {localDisplayName = c} RatchetSyncProgress {ratchetSyncStatus = rss} = +viewContactRatchetSync ct RatchetSyncProgress {ratchetSyncStatus = rss} = [ttyContact' ct <> ": " <> (plain . ratchetSyncStatusToText) rss] <> help where - help = ["use " <> highlight ("/sync " <> c) <> " to synchronize" | rss `elem` [RSAllowed, RSRequired]] + help = ["use " <> highlight ("/sync " <> viewContactName ct) <> " to synchronize" | rss `elem` [RSAllowed, RSRequired]] viewGroupMemberRatchetSync :: GroupInfo -> GroupMember -> RatchetSyncProgress -> [StyledString] -viewGroupMemberRatchetSync g m@GroupMember {localDisplayName = n} RatchetSyncProgress {ratchetSyncStatus = rss} = +viewGroupMemberRatchetSync g m RatchetSyncProgress {ratchetSyncStatus = rss} = [ttyGroup' g <> " " <> ttyMember m <> ": " <> (plain . ratchetSyncStatusToText) rss] <> help where - help = ["use " <> highlight ("/sync #" <> groupName' g <> " " <> n) <> " to synchronize" | rss `elem` [RSAllowed, RSRequired]] + help = ["use " <> highlight ("/sync #" <> viewGroupName g <> " " <> viewMemberName m) <> " to synchronize" | rss `elem` [RSAllowed, RSRequired]] viewContactVerificationReset :: Contact -> [StyledString] viewContactVerificationReset ct = @@ -1077,10 +1074,10 @@ viewGroupMemberVerificationReset g m = [ttyGroup' g <> " " <> ttyMember m <> ": security code changed"] viewContactCode :: Contact -> Text -> Bool -> [StyledString] -viewContactCode ct@Contact {localDisplayName = c} = viewSecurityCode (ttyContact' ct) ("/verify " <> c <> " ") +viewContactCode ct = viewSecurityCode (ttyContact' ct) ("/verify " <> viewContactName ct <> " ") viewGroupMemberCode :: GroupInfo -> GroupMember -> Text -> Bool -> [StyledString] -viewGroupMemberCode g m@GroupMember {localDisplayName = n} = viewSecurityCode (ttyGroup' g <> " " <> ttyMember m) ("/verify #" <> groupName' g <> " " <> n <> " ") +viewGroupMemberCode g m = viewSecurityCode (ttyGroup' g <> " " <> ttyMember m) ("/verify #" <> viewGroupName g <> " " <> viewMemberName m <> " ") viewSecurityCode :: StyledString -> Text -> Text -> Bool -> [StyledString] viewSecurityCode name cmd code testView @@ -1206,9 +1203,9 @@ bold' :: String -> StyledString bold' = styled Bold viewContactAliasUpdated :: Contact -> [StyledString] -viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}} - | localAlias == "" = ["contact " <> ttyContact n <> " alias removed"] - | otherwise = ["contact " <> ttyContact n <> " alias updated: " <> plain localAlias] +viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}} + | localAlias == "" = ["contact " <> ttyContact' ct <> " alias removed"] + | otherwise = ["contact " <> ttyContact' ct <> " alias updated: " <> plain localAlias] viewConnectionAliasUpdated :: PendingContactConnection -> [StyledString] viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias} @@ -1385,10 +1382,10 @@ savingFile' testView (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, f savingFile' _ _ = ["saving file"] -- shouldn't happen receivingFile_' :: StyledString -> AChatItem -> [StyledString] -receivingFile_' status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = - [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact c] -receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv GroupMember {localDisplayName = m}}) = - [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact m] +receivingFile_' status (AChatItem _ _ (DirectChat c) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = + [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact' c] +receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv m}) = + [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyMember m] receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] @@ -1556,6 +1553,9 @@ viewChatError logLevel = \case CEEmptyUserPassword _ -> ["user password is required"] CEUserAlreadyHidden _ -> ["user is already hidden"] CEUserNotHidden _ -> ["user is not hidden"] + CEInvalidDisplayName {displayName, validName} -> map plain $ + ["invalid display name: " <> viewName displayName] + <> ["you could use this one: " <> viewName validName | not (T.null validName)] CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] @@ -1568,8 +1568,8 @@ viewChatError logLevel = \case ] CEContactNotFound cName m_ -> viewContactNotFound cName m_ CEContactNotReady c -> [ttyContact' c <> ": not ready"] + CEContactDisabled ct -> [ttyContact' ct <> ": disabled, to enable: " <> highlight ("/enable " <> viewContactName ct) <> ", to delete: " <> highlight ("/d " <> viewContactName ct)] CEContactNotActive c -> [ttyContact' c <> ": not active"] - CEContactDisabled Contact {localDisplayName = c} -> [ttyContact c <> ": disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)] CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning] CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"] @@ -1581,7 +1581,7 @@ viewChatError logLevel = \case CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"] CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"] CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"] - CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> groupName' g)] + CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> viewGroupName g)] CEGroupMemberNotActive -> ["your group connection is not active yet, try later"] CEGroupMemberUserRemoved -> ["you are no longer a member of the group"] CEGroupMemberNotFound -> ["group doesn't have this member"] @@ -1641,8 +1641,8 @@ viewChatError logLevel = \case SEFileIdNotFoundBySharedMsgId _ -> [] -- recipient tried to accept cancelled file SEConnectionNotFound agentConnId -> ["event connection not found, agent ID: " <> sShow agentConnId | logLevel <= CLLWarning] -- mutes delete group error SEChatItemNotFoundByText text -> ["message not found by text: " <> plain text] - SEDuplicateGroupLink g -> ["you already have link for this group, to show: " <> highlight ("/show link #" <> groupName' g)] - SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> groupName' g)] + SEDuplicateGroupLink g -> ["you already have link for this group, to show: " <> highlight ("/show link #" <> viewGroupName g)] + SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> viewGroupName g)] e -> ["chat db error: " <> sShow e] ChatErrorDatabase err -> case err of DBErrorEncrypted -> ["error: chat database is already encrypted"] @@ -1687,8 +1687,8 @@ viewChatError logLevel = \case viewConnectionEntityDisabled :: ConnectionEntity -> [StyledString] viewConnectionEntityDisabled entity = case entity of - RcvDirectMsgConnection _ (Just Contact {localDisplayName = c}) -> ["[" <> entityLabel <> "] connection is disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)] - RcvGroupMsgConnection _ GroupInfo {localDisplayName = g} GroupMember {localDisplayName = m} -> ["[" <> entityLabel <> "] connection is disabled, to enable: " <> highlight ("/enable #" <> g <> " " <> m)] + RcvDirectMsgConnection _ (Just c) -> ["[" <> entityLabel <> "] connection is disabled, to enable: " <> highlight ("/enable " <> viewContactName c) <> ", to delete: " <> highlight ("/d " <> viewContactName c)] + RcvGroupMsgConnection _ g m -> ["[" <> entityLabel <> "] connection is disabled, to enable: " <> highlight ("/enable #" <> viewGroupName g <> " " <> viewMemberName m)] _ -> ["[" <> entityLabel <> "] connection is disabled"] where entityLabel = connEntityLabel entity @@ -1703,7 +1703,7 @@ connEntityLabel = \case UserContactConnection _ UserContact {} -> "contact address" ttyContact :: ContactName -> StyledString -ttyContact = styled $ colored Green +ttyContact = styled (colored Green) . viewName ttyContact' :: Contact -> StyledString ttyContact' Contact {localDisplayName = c} = ttyContact c @@ -1723,37 +1723,46 @@ ttyFullName :: ContactName -> Text -> StyledString ttyFullName c fullName = ttyContact c <> optFullName c fullName ttyToContact :: ContactName -> StyledString -ttyToContact c = ttyTo $ "@" <> c <> " " +ttyToContact c = ttyTo $ "@" <> viewName c <> " " ttyToContact' :: Contact -> StyledString ttyToContact' ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyToContact c ttyToContactEdited' :: Contact -> StyledString -ttyToContactEdited' ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyTo ("@" <> c <> " [edited] ") +ttyToContactEdited' ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyTo ("@" <> viewName c <> " [edited] ") ttyQuotedContact :: Contact -> StyledString -ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ c <> ">" +ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ viewName c <> ">" ttyQuotedMember :: Maybe GroupMember -> StyledString -ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom c +ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom (viewName c) ttyQuotedMember _ = "> " <> ttyFrom "?" ttyFromContact :: Contact -> StyledString -ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (c <> "> ") +ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> ") ttyFromContactEdited :: Contact -> StyledString -ttyFromContactEdited ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (c <> "> [edited] ") +ttyFromContactEdited ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> [edited] ") ttyFromContactDeleted :: Contact -> Maybe Text -> StyledString ttyFromContactDeleted ct@Contact {localDisplayName = c} deletedText_ = - ctIncognito ct <> ttyFrom (c <> "> " <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) + ctIncognito ct <> ttyFrom (viewName c <> "> " <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) ttyGroup :: GroupName -> StyledString -ttyGroup g = styled (colored Blue) $ "#" <> g +ttyGroup g = styled (colored Blue) $ "#" <> viewName g ttyGroup' :: GroupInfo -> StyledString ttyGroup' = ttyGroup . groupName' +viewContactName :: Contact -> Text +viewContactName = viewName . localDisplayName' + +viewGroupName :: GroupInfo -> Text +viewGroupName = viewName . groupName' + +viewMemberName :: GroupMember -> Text +viewMemberName GroupMember {localDisplayName = n} = viewName n + ttyGroups :: [GroupName] -> StyledString ttyGroups [] = "" ttyGroups [g] = ttyGroup g @@ -1774,8 +1783,7 @@ ttyFromGroupDeleted g m deletedText_ = membershipIncognito g <> ttyFrom (fromGroup_ g m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) fromGroup_ :: GroupInfo -> GroupMember -> Text -fromGroup_ GroupInfo {localDisplayName = g} GroupMember {localDisplayName = m} = - "#" <> g <> " " <> m <> "> " +fromGroup_ g m = "#" <> viewGroupName g <> " " <> viewMemberName m <> "> " ttyFrom :: Text -> StyledString ttyFrom = styled $ colored Yellow @@ -1784,12 +1792,13 @@ ttyTo :: Text -> StyledString ttyTo = styled $ colored Cyan ttyToGroup :: GroupInfo -> StyledString -ttyToGroup g@GroupInfo {localDisplayName = n} = - membershipIncognito g <> ttyTo ("#" <> n <> " ") +ttyToGroup g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " ") ttyToGroupEdited :: GroupInfo -> StyledString -ttyToGroupEdited g@GroupInfo {localDisplayName = n} = - membershipIncognito g <> ttyTo ("#" <> n <> " [edited] ") +ttyToGroupEdited g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " [edited] ") + +viewName :: Text -> Text +viewName s = if T.any isSpace s then "'" <> s <> "'" else s ttyFilePath :: FilePath -> StyledString ttyFilePath = plain diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 44af70a65e..da6cbd156f 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -8,7 +8,7 @@ import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import qualified Data.Text as T -import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..)) +import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..), Profile (..)) import System.Directory (copyFile, createDirectoryIfMissing) import Test.Hspec @@ -17,6 +17,7 @@ chatProfileTests = do describe "user profiles" $ do it "update user profile and notify contacts" testUpdateProfile it "update user profile with image" testUpdateProfileImage + it "use multiword profile names" testMultiWordProfileNames describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "add contact link to profile" testProfileLink @@ -62,7 +63,7 @@ testUpdateProfile = createGroup3 "team" alice bob cath alice ##> "/p" alice <## "user profile: alice (Alice)" - alice <## "use /p [] to change it" + alice <## "use /p to change it" alice <## "(the updated profile will be sent to all your contacts)" alice ##> "/p alice" concurrentlyN_ @@ -117,6 +118,76 @@ testUpdateProfileImage = bob <## "use @alice2 to send messages" (bob FilePath -> IO () +testMultiWordProfileNames = + testChat3 aliceProfile' bobProfile' cathProfile' $ + \alice bob cath -> do + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (bob <## "'Alice Jones': contact is connected") + (alice <## "'Bob James': contact is connected") + alice #> "@'Bob James' hi" + bob <# "'Alice Jones'> hi" + alice ##> "/g 'Our Team'" + alice <## "group #'Our Team' is created" + alice <## "to add members use /a 'Our Team' or /create link #'Our Team'" + alice ##> "/a 'Our Team' 'Bob James' admin" + alice <## "invitation to join the group #'Our Team' sent to 'Bob James'" + bob <## "#'Our Team': 'Alice Jones' invites you to join the group as admin" + bob <## "use /j 'Our Team' to accept" + bob ##> "/j 'Our Team'" + bob <## "#'Our Team': you joined the group" + alice <## "#'Our Team': 'Bob James' joined the group" + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrently_ + (cath <## "'Bob James': contact is connected") + (bob <## "'Cath Johnson': contact is connected") + bob ##> "/a 'Our Team' 'Cath Johnson'" + bob <## "invitation to join the group #'Our Team' sent to 'Cath Johnson'" + cath <## "#'Our Team': 'Bob James' invites you to join the group as member" + cath <## "use /j 'Our Team' to accept" + cath ##> "/j 'Our Team'" + concurrentlyN_ + [ bob <## "#'Our Team': 'Cath Johnson' joined the group", + do + cath <## "#'Our Team': you joined the group" + cath <## "#'Our Team': member 'Alice Jones' is connected", + do + alice <## "#'Our Team': 'Bob James' added 'Cath Johnson' to the group (connecting...)" + alice <## "#'Our Team': new member 'Cath Johnson' is connected" + ] + bob #> "#'Our Team' hi" + alice <# "#'Our Team' 'Bob James'> hi" + cath <# "#'Our Team' 'Bob James'> hi" + alice `send` "@'Cath Johnson' hello" + alice <## "member #'Our Team' 'Cath Johnson' does not have direct connection, creating" + alice <## "contact for member #'Our Team' 'Cath Johnson' is created" + alice <## "sent invitation to connect directly to member #'Our Team' 'Cath Johnson'" + alice <# "@'Cath Johnson' hello" + cath <## "#'Our Team' 'Alice Jones' is creating direct contact 'Alice Jones' with you" + cath <# "'Alice Jones'> hello" + cath <## "'Alice Jones': contact is connected" + alice <## "'Cath Johnson': contact is connected" + cath ##> "/p 'Cath J'" + cath <## "user profile is changed to 'Cath J' (your 2 contacts are notified)" + alice <## "contact 'Cath Johnson' changed to 'Cath J'" + alice <## "use @'Cath J' to send messages" + bob <## "contact 'Cath Johnson' changed to 'Cath J'" + bob <## "use @'Cath J' to send messages" + alice #> "@'Cath J' hi" + cath <# "'Alice Jones'> hi" + where + aliceProfile' = baseProfile {displayName = "Alice Jones"} + bobProfile' = baseProfile {displayName = "Bob James"} + cathProfile' = baseProfile {displayName = "Cath Johnson"} + baseProfile = Profile {displayName = "", fullName = "", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} + testUserContactLink :: HasCallStack => FilePath -> IO () testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index c120d661ff..6831cf3190 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -435,7 +435,7 @@ lastItemId cc = do showActiveUser :: HasCallStack => TestCC -> String -> Expectation showActiveUser cc name = do cc <## ("user profile: " <> name) - cc <## "use /p [] to change it" + cc <## "use /p to change it" cc <## "(the updated profile will be sent to all your contacts)" connectUsers :: HasCallStack => TestCC -> TestCC -> IO () diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 0b965250b4..69c2207ff6 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -61,6 +61,8 @@ mobileTests = do it "utf8 name 1" $ testFileEncryptionCApi "тест" it "utf8 name 2" $ testFileEncryptionCApi "👍" it "no exception on missing file" testMissingFileEncryptionCApi + describe "validate name" $ do + it "should convert invalid name to a valid name" testValidNameCApi noActiveUser :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -266,6 +268,14 @@ testMissingFileEncryptionCApi tmp = do err' <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' err' `shouldContain` toPath +testValidNameCApi :: FilePath -> IO () +testValidNameCApi _ = do + let goodName = "Джон Доу 👍" + cName1 <- cChatValidName =<< newCString goodName + peekCString cName1 `shouldReturn` goodName + cName2 <- cChatValidName =<< newCString " @'Джон' Доу 👍 " + peekCString cName2 `shouldReturn` goodName + jDecode :: FromJSON a => String -> IO (Maybe a) jDecode = pure . J.decode . LB.pack diff --git a/tests/Test.hs b/tests/Test.hs index 455d5459c7..cf60a70138 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -12,6 +12,7 @@ import SchemaDump import Test.Hspec import UnliftIO.Temporary (withTempDirectory) import ViewTests +import ValidNames import WebRTCTests main :: IO () @@ -23,6 +24,7 @@ main = do describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests describe "WebRTC encryption" webRTCTests + describe "Valid names" validNameTests around testBracket $ do describe "Mobile API Tests" mobileTests describe "SimpleX chat client" chatTests diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs new file mode 100644 index 0000000000..40cda01431 --- /dev/null +++ b/tests/ValidNames.hs @@ -0,0 +1,27 @@ +module ValidNames where + +import Simplex.Chat +import Test.Hspec + +validNameTests :: Spec +validNameTests = describe "valid chat names" $ do + it "should keep valid and fix invalid names" testMkValidName + +testMkValidName :: IO () +testMkValidName = do + mkValidName "alice" `shouldBe` "alice" + mkValidName "алиса" `shouldBe` "алиса" + mkValidName "John Doe" `shouldBe` "John Doe" + mkValidName "J.Doe" `shouldBe` "J.Doe" + mkValidName "J. Doe" `shouldBe` "J. Doe" + mkValidName "J..Doe" `shouldBe` "J.Doe" + mkValidName "J ..Doe" `shouldBe` "J Doe" + mkValidName "J . . Doe" `shouldBe` "J Doe" + mkValidName "@alice" `shouldBe` "alice" + mkValidName "#alice" `shouldBe` "alice" + mkValidName " alice" `shouldBe` "alice" + mkValidName "alice " `shouldBe` "alice" + mkValidName "John Doe" `shouldBe` "John Doe" + mkValidName "'John Doe'" `shouldBe` "John Doe" + mkValidName "\"John Doe\"" `shouldBe` "John Doe" + mkValidName "`John Doe`" `shouldBe` "John Doe" From cc95fa6b305c7967b4c0b7a2ac0fac9310fb2ead Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 3 Oct 2023 20:46:17 +0800 Subject: [PATCH 035/294] desktop: paste files/images to attach to message (#3165) * desktop: paste files/images to attach to message * Windows * copy files inside the app * change * encrypted files support --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../platform/PlatformTextField.android.kt | 1 + .../views/chat/item/ChatItemView.android.kt | 6 +++ .../common/platform/PlatformTextField.kt | 3 ++ .../chat/simplex/common/views/TerminalView.kt | 1 + .../simplex/common/views/chat/ChatView.kt | 12 +----- .../simplex/common/views/chat/ComposeView.kt | 12 ++++++ .../simplex/common/views/chat/SendMsgView.kt | 8 +++- .../common/views/chat/item/ChatItemView.kt | 4 +- .../platform/PlatformTextField.desktop.kt | 39 ++++++++++++++++++- .../views/chat/item/ChatItemView.desktop.kt | 30 ++++++++++++-- 10 files changed, 97 insertions(+), 19 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 10faa1a82b..1bc9658496 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -50,6 +50,7 @@ actual fun PlatformTextField( userIsObserver: Boolean, onMessageChange: (String) -> Unit, onUpArrow: () -> Unit, + onFilesPasted: (List) -> Unit, onDone: () -> Unit, ) { val cs = composeState.value diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index 8bb70c4a09..3aa4a92619 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -5,6 +5,8 @@ import android.os.Build import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem @@ -41,3 +43,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL showMenu.value = false }) } + +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { + clipboard.setText(AnnotatedString(cItem.content.text)) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index 95b6a73ca4..fa99d0f93c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.text.TextStyle import chat.simplex.common.views.chat.ComposeState +import java.io.File +import java.net.URI @Composable expect fun PlatformTextField( @@ -14,5 +16,6 @@ expect fun PlatformTextField( userIsObserver: Boolean, onMessageChange: (String) -> Unit, onUpArrow: () -> Unit, + onFilesPasted: (List) -> Unit, onDone: () -> Unit, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index c4f2a2cbde..a471b5645e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -97,6 +97,7 @@ fun TerminalLayout( updateLiveMessage = null, editPrevMessage = {}, onMessageChange = ::onMessageChange, + onFilesPasted = {}, textStyle = textStyle ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index ee9e109a7b..4afcdacbd8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -458,17 +458,7 @@ fun ChatLayout( .fillMaxWidth() .desktopOnExternalDrag( enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, - onFiles = { paths -> - val uris = paths.map { URI.create(it) } - val groups = uris.groupBy { isImage(it) } - val images = groups[true] ?: emptyList() - val files = groups[false] ?: emptyList() - if (images.isNotEmpty()) { - CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(images, null) } - } else if (files.isNotEmpty()) { - composeState.processPickedFile(uris.first(), null) - } - }, + onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) }, onImage = { val tmpFile = File.createTempFile("image", ".bmp", tmpDir) tmpFile.deleteOnExit() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index c6e6ca7b7a..972bc6621f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -159,6 +159,17 @@ expect fun AttachmentSelection( processPickedMedia: (List, String?) -> Unit ) +fun MutableState.onFilesAttached(uris: List) { + val groups = uris.groupBy { isImage(it) } + val images = groups[true] ?: emptyList() + val files = groups[false] ?: emptyList() + if (images.isNotEmpty()) { + CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) } + } else if (files.isNotEmpty()) { + processPickedFile(uris.first(), null) + } +} + fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { val fileSize = getFileSize(uri) @@ -816,6 +827,7 @@ fun ComposeView( chatModel.removeLiveDummy() }, editPrevMessage = ::editPrevMessage, + onFilesPasted = { composeState.onFilesAttached(it) }, onMessageChange = ::onMessageChange, textStyle = textStyle ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 2d696b7781..28882e6b73 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -29,6 +29,8 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* +import java.io.File +import java.net.URI @Composable fun SendMsgView( @@ -52,6 +54,7 @@ fun SendMsgView( updateLiveMessage: (suspend () -> Unit)? = null, cancelLiveMessage: (() -> Unit)? = null, editPrevMessage: () -> Unit, + onFilesPasted: (List) -> Unit, onMessageChange: (String) -> Unit, textStyle: MutableState ) { @@ -79,7 +82,7 @@ fun SendMsgView( val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } - PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage) { + PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { if (!cs.inProgress) { sendMessage(null) } @@ -612,6 +615,7 @@ fun PreviewSendMsgView() { sendMessage = {}, editPrevMessage = {}, onMessageChange = { _ -> }, + onFilesPasted = {}, textStyle = textStyle ) } @@ -645,6 +649,7 @@ fun PreviewSendMsgViewEditing() { sendMessage = {}, editPrevMessage = {}, onMessageChange = { _ -> }, + onFilesPasted = {}, textStyle = textStyle ) } @@ -678,6 +683,7 @@ fun PreviewSendMsgViewInProgress() { sendMessage = {}, editPrevMessage = {}, onMessageChange = { _ -> }, + onFilesPasted = {}, textStyle = textStyle ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index b5236e2491..dd07a3fc19 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -201,7 +201,7 @@ fun ChatItemView( showMenu.value = false }) ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - clipboard.setText(AnnotatedString(cItem.content.text)) + copyItemToClipboard(cItem, clipboard) showMenu.value = false }) if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { @@ -561,6 +561,8 @@ private fun showMsgDeliveryErrorAlert(description: String) { ) } +expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) + @Preview @Composable fun PreviewChatItemView() { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index 3b7ba84863..c677edc066 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.* @@ -27,6 +26,9 @@ import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay +import java.io.File +import java.net.URI +import kotlin.io.path.* import kotlin.math.min import kotlin.text.substring @@ -39,6 +41,7 @@ actual fun PlatformTextField( userIsObserver: Boolean, onMessageChange: (String) -> Unit, onUpArrow: () -> Unit, + onFilesPasted: (List) -> Unit, onDone: () -> Unit, ) { val cs = composeState.value @@ -63,10 +66,20 @@ actual fun PlatformTextField( val isRtl = remember(cs.message) { isRtl(cs.message.subSequence(0, min(50, cs.message.length))) } var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message)) } val textFieldValue = textFieldValueState.copy(text = cs.message) + val clipboard = LocalClipboardManager.current BasicTextField( value = textFieldValue, - onValueChange = { + onValueChange = onValueChange@ { if (!composeState.value.inProgress && !(composeState.value.preview is ComposePreview.VoicePreview && it.text != "")) { + val diff = textFieldValueState.selection.length + (it.text.length - textFieldValueState.text.length) + if (diff > 1 && it.text != textFieldValueState.text && it.selection.max - diff >= 0) { + val pasted = it.text.substring(it.selection.max - diff, it.selection.max) + val files = parseToFiles(AnnotatedString(pasted)) + if (files.isNotEmpty()) { + onFilesPasted(files) + return@onValueChange + } + } textFieldValueState = it onMessageChange(it.text) } @@ -98,6 +111,12 @@ actual fun PlatformTextField( } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.isEmpty()) { onUpArrow() true + } else if (it.key == Key.V && + it.type == KeyEventType.KeyDown && + ((it.isCtrlPressed && !desktopPlatform.isMac()) || (it.isMetaPressed && desktopPlatform.isMac())) && + parseToFiles(clipboard.getText()).isNotEmpty()) { + onFilesPasted(parseToFiles(clipboard.getText())) + true } else false }, @@ -142,3 +161,19 @@ private fun ComposeOverlay(textId: StringResource, textStyle: MutableState { + text ?: return emptyList() + val files = ArrayList() + text.lines().forEach { + try { + val uri = File(it.removePrefix("\"").removeSuffix("\"")).toURI() + val path = uri.toPath() + if (!path.exists() || !path.isAbsolute || path.isDirectory()) return emptyList() + files.add(uri) + } catch (e: Exception) { + return emptyList() + } + } + return files +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index c1d9eeec52..9df5bd0a12 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -7,17 +7,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.padding +import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.* -import chat.simplex.common.model.ChatItem -import chat.simplex.common.model.MsgContent -import chat.simplex.common.platform.FileChooserLauncher -import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.model.* +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import java.io.File +import java.util.* @Composable actual fun ReactionIcon(text: String, fontSize: TextUnit) { @@ -39,3 +41,23 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL showMenu.value = false }) } + +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { + val fileSource = getLoadedFileSource(cItem.file) + if (fileSource != null) { + val filePath: String = if (fileSource.cryptoArgs != null) { + val tmpFile = File(tmpDir, fileSource.filePath) + tmpFile.deleteOnExit() + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + tmpFile.absolutePath + } else { + getAppFilePath(fileSource.filePath) + } + when { + desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\"")) + else -> clipboard.setText(AnnotatedString(filePath)) + } + } else { + clipboard.setText(AnnotatedString(cItem.content.text)) + } +} From 0bcf5c9c66e82b2aee2bac2b39b4648d96cc6e7d Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 4 Oct 2023 18:36:10 +0300 Subject: [PATCH 036/294] Add commands for remote session credentials (#3161) * Add remote host commands * Make startRemoteHost async * Add tests * Trim randomStorePath to 16 chars * Add chat command tests * add view, use view output in test * enable all tests * Fix discovery listener host Must use any, not broadcast on macos. * Fix missing do * address, names * Fix session host flow * fix test --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 29 +-- src/Simplex/Chat/Controller.hs | 29 ++- .../Migrations/M20230922_remote_controller.hs | 19 +- src/Simplex/Chat/Migrations/chat_schema.sql | 18 +- src/Simplex/Chat/Remote.hs | 238 ++++++++++++------ src/Simplex/Chat/Remote/Discovery.hs | 100 ++++---- src/Simplex/Chat/Remote/Types.hs | 38 +-- src/Simplex/Chat/Store/Remote.hs | 42 ++-- src/Simplex/Chat/View.hs | 46 +++- tests/RemoteTests.hs | 148 +++++++++++ tests/Test.hs | 2 + 13 files changed, 515 insertions(+), 197 deletions(-) create mode 100644 tests/RemoteTests.hs diff --git a/cabal.project b/cabal.project index af664652db..f5bb879762 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: ec1b72cb8013a65a5d9783104a47ae44f5730089 + tag: 753a6c7542c3764fda9ce3f4c4cdc9f2329816d3 source-repository-package type: git diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 986c4966b0..75ca58c432 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -490,6 +490,7 @@ test-suite simplex-chat-test MarkdownTests MobileTests ProtocolTests + RemoteTests SchemaDump ViewTests WebRTCTests diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 92a29b7ac9..0112b76373 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1835,18 +1835,18 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} - CreateRemoteHost _displayName -> pure $ chatCmdError Nothing "not supported" - ListRemoteHosts -> pure $ chatCmdError Nothing "not supported" + CreateRemoteHost -> createRemoteHost + ListRemoteHosts -> listRemoteHosts StartRemoteHost rh -> startRemoteHost rh - StopRemoteHost rh -> closeRemoteHostSession rh $> CRRemoteHostStopped rh - DisposeRemoteHost _rh -> pure $ chatCmdError Nothing "not supported" + StopRemoteHost rh -> closeRemoteHostSession rh + DeleteRemoteHost rh -> deleteRemoteHost rh StartRemoteCtrl -> startRemoteCtrl - ConfirmRemoteCtrl rc -> confirmRemoteCtrl rc + AcceptRemoteCtrl rc -> acceptRemoteCtrl rc RejectRemoteCtrl rc -> rejectRemoteCtrl rc StopRemoteCtrl rc -> stopRemoteCtrl rc - RegisterRemoteCtrl _displayName _oobData -> pure $ chatCmdError Nothing "not supported" - ListRemoteCtrls -> pure $ chatCmdError Nothing "not supported" - DisposeRemoteCtrl rc -> disposeRemoteCtrl rc + RegisterRemoteCtrl oob -> registerRemoteCtrl oob + ListRemoteCtrls -> listRemoteCtrls + DeleteRemoteCtrl rc -> deleteRemoteCtrl rc QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) @@ -5609,17 +5609,19 @@ chatCommandP = "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, - "/create remote host" *> (CreateRemoteHost <$> textP), + "/create remote host" $> CreateRemoteHost, "/list remote hosts" $> ListRemoteHosts, "/start remote host " *> (StartRemoteHost <$> A.decimal), "/stop remote host " *> (StopRemoteHost <$> A.decimal), - "/dispose remote host " *> (DisposeRemoteHost <$> A.decimal), + "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), "/start remote ctrl" $> StartRemoteCtrl, - "/register remote ctrl " *> (RegisterRemoteCtrl <$> textP <*> remoteHostOOBP), - "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), + -- TODO *** you need to pass multiple parameters here + "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP)), + "/list remote ctrls" $> ListRemoteCtrls, + "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), "/stop remote ctrl " *> (StopRemoteCtrl <$> A.decimal), - "/dispose remote ctrl " *> (DisposeRemoteCtrl <$> A.decimal), + "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, "/debug locks" $> DebugLocks, @@ -5737,7 +5739,6 @@ chatCommandP = srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} char_ = optional . A.char - remoteHostOOBP = RemoteHostOOB <$> textP adminContactReq :: ConnReqContact adminContactReq = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a5f3d55b67..9266e2292a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -414,18 +414,18 @@ data ChatCommand | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) - | CreateRemoteHost Text -- ^ Configure a new remote host + | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost RemoteHostId -- ^ Start and announce a remote host | StopRemoteHost RemoteHostId -- ^ Shut down a running session - | DisposeRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data - | RegisterRemoteCtrl Text RemoteHostOOB -- ^ Register OOB data for satellite discovery and handshake + | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data + | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers | ListRemoteCtrls - | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm discovered data and store confirmation + | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data | StopRemoteCtrl RemoteCtrlId -- ^ Stop listening for announcements or terminate an active session - | DisposeRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session + | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session | QuitChat | ShowVersion | DebugLocks @@ -597,22 +597,23 @@ data ChatResponse | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} - | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteHostOOB} + | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteCtrlOOB} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} -- XXX: RemoteHostInfo is mostly concerned with session setup | CRRemoteHostStarted {remoteHostId :: RemoteHostId} + | CRRemoteHostConnected {remoteHostId :: RemoteHostId} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} - | CRRemoteHostDisposed {remoteHostId :: RemoteHostId} + | CRRemoteHostDeleted {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlStarted | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation - | CRRemoteCtrlFound {remoteCtrl::RemoteCtrl} -- registered fingerprint, may connect - -- | CRRemoteCtrlFirstContact {remoteCtrlId :: RemoteCtrlId, displayName :: Text} + | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrl} -- registered fingerprint, may connect | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlConnecting {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlStopped {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlDisposed {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlDeleted {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -656,13 +657,14 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" -data RemoteHostOOB = RemoteHostOOB - { fingerprint :: Text -- CA key fingerprint +data RemoteCtrlOOB = RemoteCtrlOOB + { caFingerprint :: C.KeyHash } deriving (Show, Generic, ToJSON) data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, + storePath :: FilePath, displayName :: Text, sessionActive :: Bool } @@ -673,7 +675,7 @@ data RemoteCtrlInfo = RemoteCtrlInfo displayName :: Text, sessionActive :: Bool } - deriving (Show, Generic, ToJSON) + deriving (Eq, Show, Generic, ToJSON) newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -1052,6 +1054,7 @@ data RemoteCtrlError | RCEConnectionLost {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected due to transport issues | RCECertificateExpired {remoteCtrlId :: RemoteCtrlId} -- ^ A connection or CA certificate in a chain have bad validity period | RCECertificateUntrusted {remoteCtrlId :: RemoteCtrlId} -- ^ TLS is unable to validate certificate chain presented for a connection + | RCEBadFingerprint -- ^ Bad fingerprint data provided in OOB deriving (Show, Exception, Generic) instance FromJSON RemoteCtrlError where diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs index d2ca386b0e..21d653d124 100644 --- a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs +++ b/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs @@ -9,18 +9,19 @@ m20230922_remote_controller :: Query m20230922_remote_controller = [sql| CREATE TABLE remote_hosts ( -- hosts known to a controlling app - remote_host_id INTEGER PRIMARY KEY, - display_name TEXT NOT NULL, - store_path TEXT NOT NULL, - ca_cert BLOB NOT NULL, - ca_key BLOB NOT NULL + remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, + store_path TEXT NOT NULL, -- file path relative to app store (must not contain "/") + display_name TEXT NOT NULL, -- user-provided name for a remote host + ca_key BLOB NOT NULL, -- private key for signing session certificates + ca_cert BLOB NOT NULL, -- root certificate, whose fingerprint is pinned on a remote + contacted INTEGER NOT NULL DEFAULT 0 -- 0 (first time), 1 (connected before) ); CREATE TABLE remote_controllers ( -- controllers known to a hosting app - remote_controller_id INTEGER PRIMARY KEY, - display_name TEXT NOT NULL, - fingerprint BLOB NOT NULL, - accepted INTEGER -- unknown/rejected/confirmed + remote_controller_id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, -- user-provided name for a remote controller + fingerprint BLOB NOT NULL, -- remote controller CA fingerprint + accepted INTEGER -- NULL (unknown), 0 (rejected), 1 (confirmed) ); |] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index e6be03bcc2..36ebe61209 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -518,17 +518,19 @@ CREATE TABLE IF NOT EXISTS "received_probes"( ); CREATE TABLE remote_hosts( -- hosts known to a controlling app - remote_host_id INTEGER PRIMARY KEY, - display_name TEXT NOT NULL, - store_path TEXT NOT NULL, - ca_cert BLOB NOT NULL, - ca_key BLOB NOT NULL + remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, + store_path TEXT NOT NULL, -- file path relative to app store(must not contain "/") + display_name TEXT NOT NULL, -- user-provided name for a remote host + ca_key BLOB NOT NULL, -- private key for signing session certificates + ca_cert BLOB NOT NULL, -- root certificate, whose fingerprint is pinned on a remote + contacted INTEGER NOT NULL DEFAULT 0 -- 0(first time), 1(connected before) ); CREATE TABLE remote_controllers( -- controllers known to a hosting app - remote_controller_id INTEGER PRIMARY KEY, - display_name TEXT NOT NULL, - fingerprint BLOB NOT NULL + remote_controller_id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, -- user-provided name for a remote controller + fingerprint BLOB NOT NULL, -- remote controller CA fingerprint + accepted INTEGER -- NULL(unknown), 0(rejected), 1(confirmed) ); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 82d2e9e630..936c750c6c 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -7,11 +7,17 @@ module Simplex.Chat.Remote where +import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Control.Monad.STM (retry) +import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J import qualified Data.Binary.Builder as Binary -import Data.ByteString.Char8 (ByteString) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Char8 as B +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP2.Client as HTTP2Client @@ -21,12 +27,13 @@ import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Remote import Simplex.Chat.Types -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) +import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 import Simplex.Messaging.Util (bshow) @@ -39,29 +46,82 @@ withRemoteHostSession remoteHostId action = do where err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing +withRemoteHost :: (ChatMonad m) => RemoteHostId -> (RemoteHost -> m a) -> m a +withRemoteHost remoteHostId action = + withStore' (`getRemoteHost` remoteHostId) >>= \case + Nothing -> throwError $ ChatErrorRemoteHost remoteHostId RHMissing + Just rh -> action rh + startRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse startRemoteHost remoteHostId = do - RemoteHost {displayName = _, storePath, caKey, caCert} <- error "TODO: get from DB" - (fingerprint :: ByteString, sessionCreds) <- error "TODO: derive session creds" (caKey, caCert) - cleanup <- toIO $ chatModifyVar remoteHostSessions (M.delete remoteHostId) - Discovery.runAnnouncer cleanup fingerprint sessionCreds >>= \case - Left todo'err -> pure $ chatCmdError Nothing "TODO: Some HTTP2 error" - Right ctrlClient -> do - chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSession {storePath, ctrlClient} - pure $ CRRemoteHostStarted remoteHostId + M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case + Just _ -> throwError $ ChatErrorRemoteHost remoteHostId RHBusy + Nothing -> withRemoteHost remoteHostId run + where + run RemoteHost {storePath, caKey, caCert} = do + announcer <- async $ do + cleanup <- toIO $ closeRemoteHostSession remoteHostId >>= toView + let parent = (C.signatureKeyPair caKey, caCert) + sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" + let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] + Discovery.announceRevHTTP2 cleanup fingerprint credentials >>= \case + Left todo'err -> liftIO cleanup -- TODO: log error + Right ctrlClient -> do + chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarted {storePath, ctrlClient} + -- TODO: start streaming outputQ + toView CRRemoteHostConnected {remoteHostId} + chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarting {announcer} + pure CRRemoteHostStarted {remoteHostId} -closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () -closeRemoteHostSession rh = withRemoteHostSession rh (liftIO . HTTP2.closeHTTP2Client . ctrlClient) +closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m ChatResponse +closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do + case session of + RemoteHostSessionStarting {announcer} -> cancel announcer + RemoteHostSessionStarted {ctrlClient} -> liftIO (HTTP2.closeHTTP2Client ctrlClient) + chatModifyVar remoteHostSessions $ M.delete remoteHostId + pure CRRemoteHostStopped { remoteHostId } + +createRemoteHost :: (ChatMonad m) => m ChatResponse +createRemoteHost = do + let displayName = "TODO" -- you don't have remote host name here, it will be passed from remote host + ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) displayName + storePath <- liftIO randomStorePath + remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath displayName caKey caCert + let oobData = + RemoteCtrlOOB + { caFingerprint = C.certificateFingerprint caCert + } + pure CRRemoteHostCreated {remoteHostId, oobData} + +-- | Generate a random 16-char filepath without / in it by using base64url encoding. +randomStorePath :: IO FilePath +randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 + +listRemoteHosts :: (ChatMonad m) => m ChatResponse +listRemoteHosts = do + stored <- withStore' getRemoteHosts + active <- chatReadVar remoteHostSessions + pure $ CRRemoteHostList $ do + RemoteHost {remoteHostId, storePath, displayName} <- stored + let sessionActive = M.member remoteHostId active + pure RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} + +deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse +deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \rh -> do + -- TODO: delete files + withStore' $ \db -> deleteRemoteHostRecord db remoteHostId + pure CRRemoteHostDeleted {remoteHostId} processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse -processRemoteCommand rhs = \case +processRemoteCommand RemoteHostSessionStarting {} _ = error "TODO: sending remote commands before session started" +processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = -- XXX: intercept and filter some commands -- TODO: store missing files on remote host - (s, _cmd) -> relayCommand rhs s + relayCommand ctrlClient s -relayCommand :: (ChatMonad m) => RemoteHostSession -> ByteString -> m ChatResponse -relayCommand RemoteHostSession {ctrlClient} s = - postBytestring Nothing ctrlClient "/relay" mempty s >>= \case +relayCommand :: (ChatMonad m) => HTTP2Client -> ByteString -> m ChatResponse +relayCommand http s = + postBytestring Nothing http "/relay" mempty s >>= \case Left e -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do remoteChatResponse <- @@ -85,9 +145,15 @@ relayCommand RemoteHostSession {ctrlClient} s = where req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) -storeRemoteFile :: (ChatMonad m) => RemoteHostSession -> FilePath -> m ChatResponse -storeRemoteFile RemoteHostSession {ctrlClient} localFile = do - postFile Nothing ctrlClient "/store" mempty localFile >>= \case +-- | Convert swift single-field sum encoding into tagged/discriminator-field +sum2tagged :: J.Value -> J.Value +sum2tagged = \case + J.Object todo'convert -> J.Object todo'convert + skip -> skip + +storeRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> m ChatResponse +storeRemoteFile http localFile = do + postFile Nothing http "/store" mempty localFile >>= \case Left todo'err -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {response} -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of Just 200 -> pure $ CRCmdOk Nothing @@ -99,9 +165,9 @@ storeRemoteFile RemoteHostSession {ctrlClient} localFile = do where req size = HTTP2Client.requestFile "POST" path hs (HTTP2Client.FileSpec file 0 size) -fetchRemoteFile :: (ChatMonad m) => RemoteHostSession -> FileTransferId -> m ChatResponse -fetchRemoteFile RemoteHostSession {ctrlClient, storePath} remoteFileId = do - liftIO (HTTP2.sendRequest ctrlClient req Nothing) >>= \case +fetchRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> FileTransferId -> m ChatResponse +fetchRemoteFile http storePath remoteFileId = do + liftIO (HTTP2.sendRequest http req Nothing) >>= \case Left e -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {respBody} -> do error "TODO: stream body into a local file" -- XXX: consult headers for a file name? @@ -109,14 +175,8 @@ fetchRemoteFile RemoteHostSession {ctrlClient, storePath} remoteFileId = do req = HTTP2Client.requestNoBody "GET" path mempty path = "/fetch/" <> bshow remoteFileId --- | Convert swift single-field sum encoding into tagged/discriminator-field -sum2tagged :: J.Value -> J.Value -sum2tagged = \case - J.Object todo'convert -> J.Object todo'convert - skip -> skip - -processControllerCommand :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () -processControllerCommand rc req = error "TODO: processControllerCommand" +processControllerRequest :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () +processControllerRequest rc req = error "TODO: processControllerRequest" -- * ChatRequest handlers @@ -127,27 +187,23 @@ startRemoteCtrl = Nothing -> do accepted <- newEmptyTMVarIO discovered <- newTVarIO mempty - listener <- async $ discoverRemoteCtrls discovered - _supervisor <- async $ do - uiEvent <- async $ atomically $ readTMVar accepted - waitEitherCatchCancel listener uiEvent >>= \case - Left _ -> pure () -- discover got cancelled or crashed on some UDP error - Right (Left _) -> toView . CRChatError Nothing . ChatError $ CEException "Crashed while waiting for remote session confirmation" - Right (Right remoteCtrlId) -> - -- got connection confirmation - atomically (TM.lookup remoteCtrlId discovered) >>= \case - Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote session accepted without getting discovered first" - Just (source, fingerprint) -> do - atomically $ writeTVar discovered mempty -- flush unused sources - host <- async $ runRemoteHost remoteCtrlId source fingerprint - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = host, accepted} - _ <- waitCatch host - chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlStopped {remoteCtrlId} - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {ctrlAsync = listener, accepted} + discoverer <- async $ discoverRemoteCtrls discovered + supervisor <- async $ do + remoteCtrlId <- atomically (readTMVar accepted) + withRemoteCtrl remoteCtrlId $ \RemoteCtrl {displayName, fingerprint} -> do + source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure + toView $ CRRemoteCtrlConnecting {remoteCtrlId, displayName} + atomically $ writeTVar discovered mempty -- flush unused sources + server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest remoteCtrlId) + chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} + toView $ CRRemoteCtrlConnected {remoteCtrlId, displayName} + _ <- waitCatch server + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped {remoteCtrlId} + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted} pure CRRemoteCtrlStarted -discoverRemoteCtrls :: (ChatMonad m) => TM.TMap RemoteCtrlId (TransportHost, C.KeyHash) -> m () +discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () discoverRemoteCtrls discovered = Discovery.openListener >>= go where go sock = @@ -155,47 +211,77 @@ discoverRemoteCtrls discovered = Discovery.openListener >>= go (SockAddrInet _port addr, invite) -> case strDecode invite of Left _ -> go sock -- ignore malformed datagrams Right fingerprint -> do - withStore' (\db -> getRemoteCtrlByFingerprint (DB.conn db) fingerprint) >>= \case - Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint - Just found@RemoteCtrl {remoteCtrlId} -> do - atomically $ TM.insert remoteCtrlId (THIPv4 (hostAddressToTuple addr), fingerprint) discovered - toView $ CRRemoteCtrlFound found + atomically $ TM.insert fingerprint (THIPv4 $ hostAddressToTuple addr) discovered + withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case + Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui action required + Just found@RemoteCtrl {remoteCtrlId, accepted=storedChoice} -> case storedChoice of + Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui action required + Just False -> pure () -- skipping a rejected item + Just True -> chatReadVar remoteCtrlSession >>= \case + Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" + Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically _nonV4 -> go sock -runRemoteHost :: (ChatMonad m) => RemoteCtrlId -> TransportHost -> C.KeyHash -> m () -runRemoteHost remoteCtrlId remoteCtrlHost fingerprint = - Discovery.connectSessionHost remoteCtrlHost fingerprint $ Discovery.attachServer (processControllerCommand remoteCtrlId) +registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m ChatResponse +registerRemoteCtrl RemoteCtrlOOB {caFingerprint} = do + let displayName = "TODO" -- maybe include into OOB data + remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint + pure $ CRRemoteCtrlRegistered {remoteCtrlId} -confirmRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse -confirmRemoteCtrl remoteCtrlId = +listRemoteCtrls :: (ChatMonad m) => m ChatResponse +listRemoteCtrls = do + stored <- withStore' getRemoteCtrls + active <- + chatReadVar remoteCtrlSession >>= \case + Nothing -> pure Nothing + Just RemoteCtrlSession {accepted} -> atomically (tryReadTMVar accepted) + pure $ CRRemoteCtrlList $ do + RemoteCtrl {remoteCtrlId, displayName} <- stored + let sessionActive = active == Just remoteCtrlId + pure RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} + +acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +acceptRemoteCtrl remoteCtrlId = do + withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId True chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {accepted} -> do - withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId True - atomically $ putTMVar accepted remoteCtrlId -- the remote host can now proceed with connection - pure $ CRRemoteCtrlAccepted {remoteCtrlId} + Just RemoteCtrlSession {accepted} -> atomically . void $ tryPutTMVar accepted remoteCtrlId -- the remote host can now proceed with connection + pure $ CRRemoteCtrlAccepted {remoteCtrlId} rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse -rejectRemoteCtrl remoteCtrlId = +rejectRemoteCtrl remoteCtrlId = do + withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId False chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {ctrlAsync} -> do - withStore' $ \db -> markRemoteCtrlResolution (DB.conn db) remoteCtrlId False - cancel ctrlAsync - pure $ CRRemoteCtrlRejected {remoteCtrlId} + Just RemoteCtrlSession {discoverer, supervisor} -> do + cancel discoverer + cancel supervisor + pure $ CRRemoteCtrlRejected {remoteCtrlId} stopRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse stopRemoteCtrl remoteCtrlId = chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {ctrlAsync} -> do - cancel ctrlAsync - pure CRRemoteCtrlStopped {remoteCtrlId} + Just RemoteCtrlSession {discoverer, supervisor, hostServer} -> do + cancel discoverer -- may be gone by now + case hostServer of + Just host -> cancel host -- supervisor will clean up + Nothing -> do + cancel supervisor -- supervisor is blocked until session progresses + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped {remoteCtrlId} + pure $ CRCmdOk Nothing -disposeRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse -disposeRemoteCtrl remoteCtrlId = +deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +deleteRemoteCtrl remoteCtrlId = chatReadVar remoteCtrlSession >>= \case Nothing -> do - withStore' $ \db -> deleteRemoteCtrl (DB.conn db) remoteCtrlId - pure $ CRRemoteCtrlDisposed {remoteCtrlId} + withStore' $ \db -> deleteRemoteCtrlRecord db remoteCtrlId + pure $ CRRemoteCtrlDeleted {remoteCtrlId} Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy + +withRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrl -> m a) -> m a +withRemoteCtrl remoteCtrlId action = + withStore' (`getRemoteCtrl` remoteCtrlId) >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEMissing {remoteCtrlId} + Just rc -> action rc diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index f04d0a008a..2faed66cd8 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -1,18 +1,21 @@ {-# LANGUAGE FlexibleContexts #-} -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} module Simplex.Chat.Remote.Discovery ( -- * Announce + announceRevHTTP2, runAnnouncer, + startTLSServer, + runHTTP2Client, -- * Discovery + connectRevHTTP2, openListener, recvAnnounce, - connectSessionHost, - attachServer, + connectTLSClient, + attachHTTP2Server, ) where @@ -20,7 +23,6 @@ import Control.Monad import Data.ByteString (ByteString) import Data.Default (def) import Data.String (IsString) -import Debug.Trace import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP @@ -33,54 +35,65 @@ import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) +import Simplex.Messaging.Util (whenM) import UnliftIO import UnliftIO.Concurrent -- | Link-local broadcast address. pattern BROADCAST_ADDR_V4 :: (IsString a, Eq a) => a -pattern BROADCAST_ADDR_V4 = "255.255.255.255" +pattern BROADCAST_ADDR_V4 = "0.0.0.0" + +pattern ANY_ADDR_V4 :: (IsString a, Eq a) => a +pattern ANY_ADDR_V4 = "0.0.0.0" pattern BROADCAST_PORT :: (IsString a, Eq a) => a pattern BROADCAST_PORT = "5226" -runAnnouncer :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) -runAnnouncer finished invite credentials = do - started <- newEmptyTMVarIO - aPid <- async $ announcer started (strEncode invite) - let serverParams = - def - { TLS.serverWantClientCert = False, - TLS.serverShared = def {TLS.sharedCredentials = credentials}, - TLS.serverHooks = def, - TLS.serverSupported = supportedParameters - } +-- | Announce tls server, wait for connection and attach http2 client to it. +-- +-- Announcer is started when TLS server is started and stopped when a connection is made. +announceRevHTTP2 :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 finishAction invite credentials = do httpClient <- newEmptyMVar - liftIO $ runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig (run aPid httpClient) - takeMVar httpClient - where - announcer started inviteBS = do - atomically (takeTMVar started) >>= \case - False -> - error "Server not started?.." - True -> liftIO $ do - traceM $ "TCP server started at " <> BROADCAST_PORT - sock <- UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False - N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 - traceM $ "UDP announce started at " <> BROADCAST_ADDR_V4 <> ":" <> BROADCAST_PORT - traceM $ "Server invite: " <> show inviteBS - forever $ do - UDP.send sock inviteBS - threadDelay 1000000 + started <- newEmptyTMVarIO + finished <- newEmptyMVar + announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ runAnnouncer (strEncode invite) + tlsServer <- startTLSServer started credentials $ \tls -> cancel announcer >> runHTTP2Client finished httpClient tls + _ <- forkIO . liftIO $ do + readMVar finished + cancel tlsServer + finishAction + readMVar httpClient - run :: Async () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () - run aPid clientVar tls = do - cancel aPid - let partyHost = "255.255.255.255" -- XXX: get from tls somehow? not required as host verification is disabled. - attachHTTP2Client defaultHTTP2ClientConfig partyHost BROADCAST_PORT finished defaultHTTP2BufferSize tls >>= putMVar clientVar +-- | Broadcast invite with link-local datagrams +runAnnouncer :: ByteString -> IO () +runAnnouncer inviteBS = do + sock <- UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False + N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 + forever $ do + UDP.send sock inviteBS + threadDelay 1000000 + +startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) +startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig + where + serverParams = + def + { TLS.serverWantClientCert = False, + TLS.serverShared = def {TLS.sharedCredentials = credentials}, + TLS.serverHooks = def, + TLS.serverSupported = supportedParameters + } + +-- | Attach HTTP2 client and hold the TLS until the attached client finishes. +runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () +runHTTP2Client finishedVar clientVar tls = do + attachHTTP2Client defaultHTTP2ClientConfig ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls >>= putMVar clientVar + readMVar finishedVar openListener :: (MonadIO m) => m UDP.ListenSocket openListener = liftIO $ do - sock <- UDP.serverSocket (BROADCAST_ADDR_V4, read BROADCAST_PORT) + sock <- UDP.serverSocket (ANY_ADDR_V4, read BROADCAST_PORT) N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 pure sock @@ -89,11 +102,14 @@ recvAnnounce sock = liftIO $ do (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock pure (source, invite) -connectSessionHost :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a -connectSessionHost host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host BROADCAST_PORT (Just caFingerprint) +connectRevHTTP2 :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (HTTP2Request -> m ()) -> m () +connectRevHTTP2 host fingerprint = connectTLSClient host fingerprint . attachHTTP2Server -attachServer :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () -attachServer processRequest tls = do +connectTLSClient :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a +connectTLSClient host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host BROADCAST_PORT (Just caFingerprint) + +attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () +attachHTTP2Server processRequest tls = do withRunInIO $ \unlift -> runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do reqBody <- getHTTP2Body r defaultHTTP2BufferSize diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 5902476fc1..b66e9a6253 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -6,26 +6,26 @@ module Simplex.Chat.Remote.Types where import Control.Concurrent.Async (Async) import Data.Aeson (ToJSON (..)) -import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import GHC.Generics (Generic) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.TMap (TMap) +import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import UnliftIO.STM -import Simplex.Messaging.Encoding.String (strToJEncoding, strToJSON) type RemoteHostId = Int64 data RemoteHost = RemoteHost { remoteHostId :: RemoteHostId, - displayName :: Text, - -- | Path to store replicated files storePath :: FilePath, - -- | A stable part of X509 credentials used to access the host - caCert :: ByteString, + displayName :: Text, -- | Credentials signing key for root and session certs - caKey :: C.Key + caKey :: C.APrivateSignKey, + -- | A stable part of TLS credentials used in remote session + caCert :: C.SignedCertificate, + contacted :: Bool } deriving (Show) @@ -39,19 +39,21 @@ data RemoteCtrl = RemoteCtrl } deriving (Show, Generic, ToJSON) --- XXX: until fixed in master -instance ToJSON C.KeyHash where - toEncoding = strToJEncoding - toJSON = strToJSON - -data RemoteHostSession = RemoteHostSession - { -- | Path for local resources to be synchronized with host - storePath :: FilePath, - ctrlClient :: HTTP2Client - } +data RemoteHostSession + = RemoteHostSessionStarting + { announcer :: Async () + } + | RemoteHostSessionStarted + { -- | Path for local resources to be synchronized with host + storePath :: FilePath, + ctrlClient :: HTTP2Client + } data RemoteCtrlSession = RemoteCtrlSession { -- | Server side of transport to process remote commands and forward notifications - ctrlAsync :: Async (), + discoverer :: Async (), + supervisor :: Async (), + hostServer :: Maybe (Async ()), + discovered :: TMap C.KeyHash TransportHost, accepted :: TMVar RemoteCtrlId } diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 591f346bee..c231a535b5 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -4,14 +4,20 @@ module Simplex.Chat.Store.Remote where -import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) -import qualified Database.SQLite.Simple as DB +import Database.SQLite.Simple (Only (..)) +import qualified Database.SQLite.Simple as SQL +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Chat.Remote.Types (RemoteCtrl (..), RemoteCtrlId, RemoteHost (..), RemoteHostId) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) import qualified Simplex.Messaging.Crypto as C +insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId +insertRemoteHost db storePath displayName caKey caCert = do + DB.execute db "INSERT INTO remote_hosts (store_path, display_name, ca_key, ca_cert) VALUES (?,?,?,?)" (storePath, displayName, caKey, C.SignedObject caCert) + fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" + getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts db = map toRemoteHost <$> DB.query_ db remoteHostQuery @@ -19,14 +25,22 @@ getRemoteHosts db = getRemoteHost :: DB.Connection -> RemoteHostId -> IO (Maybe RemoteHost) getRemoteHost db remoteHostId = maybeFirstRow toRemoteHost $ - DB.query db (remoteHostQuery <> "WHERE remote_host_id = ?") (DB.Only remoteHostId) + DB.query db (remoteHostQuery <> " WHERE remote_host_id = ?") (Only remoteHostId) -remoteHostQuery :: DB.Query -remoteHostQuery = "SELECT remote_host_id, display_name, store_path, ca_cert, ca_key FROM remote_hosts" +remoteHostQuery :: SQL.Query +remoteHostQuery = "SELECT remote_host_id, store_path, display_name, ca_key, ca_cert, contacted FROM remote_hosts" -toRemoteHost :: (Int64, Text, FilePath, ByteString, C.Key) -> RemoteHost -toRemoteHost (remoteHostId, displayName, storePath, caCert, caKey) = - RemoteHost {remoteHostId, displayName, storePath, caCert, caKey} +toRemoteHost :: (Int64, FilePath, Text, C.APrivateSignKey, C.SignedObject C.Certificate, Bool) -> RemoteHost +toRemoteHost (remoteHostId, storePath, displayName, caKey, C.SignedObject caCert, contacted) = + RemoteHost {remoteHostId, storePath, displayName, caKey, caCert, contacted} + +deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () +deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) + +insertRemoteCtrl :: DB.Connection -> Text -> C.KeyHash -> IO RemoteCtrlId +insertRemoteCtrl db displayName fingerprint = do + DB.execute db "INSERT INTO remote_controllers (display_name, fingerprint) VALUES (?,?)" (displayName, fingerprint) + fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] getRemoteCtrls db = @@ -35,14 +49,14 @@ getRemoteCtrls db = getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO (Maybe RemoteCtrl) getRemoteCtrl db remoteCtrlId = maybeFirstRow toRemoteCtrl $ - DB.query db (remoteCtrlQuery <> "WHERE remote_controller_id = ?") (DB.Only remoteCtrlId) + DB.query db (remoteCtrlQuery <> " WHERE remote_controller_id = ?") (Only remoteCtrlId) getRemoteCtrlByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteCtrl) getRemoteCtrlByFingerprint db fingerprint = maybeFirstRow toRemoteCtrl $ - DB.query db (remoteCtrlQuery <> "WHERE fingerprint = ?") (DB.Only fingerprint) + DB.query db (remoteCtrlQuery <> " WHERE fingerprint = ?") (Only fingerprint) -remoteCtrlQuery :: DB.Query +remoteCtrlQuery :: SQL.Query remoteCtrlQuery = "SELECT remote_controller_id, display_name, fingerprint, accepted FROM remote_controllers" toRemoteCtrl :: (Int64, Text, C.KeyHash, Maybe Bool) -> RemoteCtrl @@ -53,6 +67,6 @@ markRemoteCtrlResolution :: DB.Connection -> RemoteCtrlId -> Bool -> IO () markRemoteCtrlResolution db remoteCtrlId accepted = DB.execute db "UPDATE remote_controllers SET accepted = ? WHERE remote_controller_id = ? AND accepted IS NULL" (accepted, remoteCtrlId) -deleteRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO () -deleteRemoteCtrl db remoteCtrlId = - DB.execute db "DELETE FROM remote_controllers WHERE remote_controller_id = ?" (DB.Only remoteCtrlId) +deleteRemoteCtrlRecord :: DB.Connection -> RemoteCtrlId -> IO () +deleteRemoteCtrlRecord db remoteCtrlId = + DB.execute db "DELETE FROM remote_controllers WHERE remote_controller_id = ?" (Only remoteCtrlId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9181351bd9..42f8d70ff0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -4,10 +4,10 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{-# LANGUAGE OverloadedRecordDot #-} module Simplex.Chat.View where @@ -42,6 +42,7 @@ import Simplex.Chat.Markdown import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol +import Simplex.Chat.Remote.Types import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types @@ -258,6 +259,23 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] + CRRemoteHostCreated rhId oobData -> ("remote host " <> sShow rhId <> " created") : viewRemoteCtrlOOBData oobData + CRRemoteHostList hs -> viewRemoteHosts hs + CRRemoteHostStarted rhId -> ["remote host " <> sShow rhId <> " started"] + CRRemoteHostConnected rhId -> ["remote host " <> sShow rhId <> " connected"] + CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] + CRRemoteHostDeleted rhId -> ["remote host " <> sShow rhId <> " deleted"] + CRRemoteCtrlList cs -> viewRemoteCtrls cs + CRRemoteCtrlRegistered rcId -> ["remote controller " <> sShow rcId <> " registered"] + CRRemoteCtrlStarted -> ["remote controller started"] + CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] + CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] + CRRemoteCtrlAccepted rcId -> ["remote controller " <> sShow rcId <> " accepted"] + CRRemoteCtrlRejected rcId -> ["remote controller " <> sShow rcId <> " rejected"] + CRRemoteCtrlConnecting rcId rcName -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] + CRRemoteCtrlConnected rcId rcName -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] + CRRemoteCtrlStopped rcId -> ["remote controller " <> sShow rcId <> " stopped"] + CRRemoteCtrlDeleted rcId -> ["remote controller " <> sShow rcId <> " deleted"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = @@ -298,7 +316,6 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRChatError u e -> ttyUser' u $ viewChatError logLevel e CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRTimedAction _ _ -> [] - todo'cr -> ["TODO" <> sShow todo'cr] where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser} ss @@ -1539,6 +1556,31 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo where parens s = " (" <> s <> ")" +viewRemoteCtrlOOBData :: RemoteCtrlOOB -> [StyledString] +viewRemoteCtrlOOBData RemoteCtrlOOB {caFingerprint} = + ["connection code:", plain $ strEncode caFingerprint] + +viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] +viewRemoteHosts = \case + [] -> ["No remote hosts"] + hs -> "Remote hosts: " : map viewRemoteHostInfo hs + where + viewRemoteHostInfo RemoteHostInfo {remoteHostId, displayName, sessionActive} = + plain $ tshow remoteHostId <> ". " <> displayName <> if sessionActive then " (active)" else "" + +viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] +viewRemoteCtrls = \case + [] -> ["No remote controllers"] + hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs + where + viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} = + plain $ tshow remoteCtrlId <> ". " <> displayName <> if sessionActive then " (active)" else "" + +-- TODO fingerprint, accepted? +viewRemoteCtrl :: RemoteCtrl -> StyledString +viewRemoteCtrl RemoteCtrl {remoteCtrlId, displayName} = + plain $ tshow remoteCtrlId <> ". " <> displayName + viewChatError :: ChatLogLevel -> ChatError -> [StyledString] viewChatError logLevel = \case ChatError err -> case err of diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs new file mode 100644 index 0000000000..d1c162187f --- /dev/null +++ b/tests/RemoteTests.hs @@ -0,0 +1,148 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} +{-# LANGUAGE OverloadedStrings #-} + +module RemoteTests where + +import ChatClient +import ChatTests.Utils +import Control.Monad +import Data.List.NonEmpty (NonEmpty (..)) +import Debug.Trace +import Network.HTTP.Types (ok200) +import qualified Network.HTTP2.Client as C +import qualified Network.HTTP2.Server as S +import qualified Network.Socket as N +import qualified Network.TLS as TLS +import qualified Simplex.Chat.Remote.Discovery as Discovery +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String +import qualified Simplex.Messaging.Transport as Transport +import Simplex.Messaging.Transport.Client (TransportHost (..)) +import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) +import Test.Hspec +import UnliftIO + +remoteTests :: SpecWith FilePath +remoteTests = describe "Handshake" $ do + it "generates usable credentials" genCredentialsTest + it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test + it "connects desktop and mobile" remoteHandshakeTest + +-- * Low-level TLS with ephemeral credentials + +genCredentialsTest :: (HasCallStack) => FilePath -> IO () +genCredentialsTest _tmp = do + (fingerprint, credentials) <- genTestCredentials + started <- newEmptyTMVarIO + server <- Discovery.startTLSServer started credentials serverHandler + ok <- atomically (readTMVar started) + unless ok $ cancel server >> error "TLS server failed to start" + Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler + cancel server + where + serverHandler serverTls = do + traceM " - Sending from server" + Transport.putLn serverTls "hi client" + traceM " - Reading from server" + Transport.getLn serverTls `shouldReturn` "hi server" + clientHandler clientTls = do + traceM " - Sending from client" + Transport.putLn clientTls "hi server" + traceM " - Reading from client" + Transport.getLn clientTls `shouldReturn` "hi client" + +-- * UDP discovery and rever HTTP2 + +announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () +announceDiscoverHttp2Test _tmp = do + (fingerprint, credentials) <- genTestCredentials + finished <- newEmptyMVar + announcer <- async $ do + traceM " - Controller: starting" + http <- Discovery.announceRevHTTP2 (putMVar finished ()) fingerprint credentials >>= either (fail . show) pure + traceM " - Controller: got client" + sendRequest http (C.requestNoBody "GET" "/" []) (Just 10000000) >>= \case + Left err -> do + traceM " - Controller: got error" + fail $ show err + Right HTTP2Response {} -> + traceM " - Controller: got response" + closeHTTP2Client http + dis <- async $ do + sock <- Discovery.openListener + (N.SockAddrInet _port addr, invite) <- Discovery.recvAnnounce sock + strDecode invite `shouldBe` Right fingerprint + traceM " - Host: connecting" + server <- async $ Discovery.connectTLSClient (THIPv4 $ N.hostAddressToTuple addr) fingerprint $ \tls -> do + traceM " - Host: got tls" + flip Discovery.attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do + traceM " - Host: got request" + sendResponse $ S.responseNoBody ok200 [] + traceM " - Host: sent response" + takeMVar finished + cancel server + traceM " - Host: finished" + waitBoth dis announcer `shouldReturn` ((), ()) + +-- * Chat commands + +remoteHandshakeTest :: HasCallStack => FilePath -> IO () +remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do + desktop ##> "/list remote hosts" + desktop <## "No remote hosts" + desktop ##> "/create remote host" + desktop <## "remote host 1 created" + desktop <## "connection code:" + fingerprint <- getTermLine desktop + + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <## "1. TODO" -- TODO host name probably should be Maybe, as when host is created there is no name yet + + desktop ##> "/start remote host 1" + desktop <## "remote host 1 started" + + mobile ##> "/start remote ctrl" + mobile <## "remote controller started" + mobile <## "remote controller announced" + mobile <## "connection code:" + fingerprint' <- getTermLine mobile + fingerprint' `shouldBe` fingerprint + mobile ##> "/list remote ctrls" + mobile <## "No remote controllers" + mobile ##> ("/register remote ctrl " <> fingerprint') + mobile <## "remote controller 1 registered" + mobile ##> "/list remote ctrls" + mobile <## "Remote controllers:" + mobile <## "1. TODO" + mobile ##> "/accept remote ctrl 1" + mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start + mobile <## "remote controller 1 connecting to TODO" + mobile <## "remote controller 1 connected, TODO" + mobile ##> "/stop remote ctrl 1" + mobile <## "ok" + mobile <## "remote controller 1 stopped" -- TODO two outputs + mobile ##> "/delete remote ctrl 1" + mobile <## "remote controller 1 deleted" + mobile ##> "/list remote ctrls" + mobile <## "No remote controllers" + + desktop ##> "/stop remote host 1" + desktop <## "remote host 1 stopped" + desktop ##> "/delete remote host 1" + desktop <## "remote host 1 deleted" + desktop ##> "/list remote hosts" + desktop <## "No remote hosts" + +-- * Utils + +genTestCredentials :: IO (C.KeyHash, TLS.Credentials) +genTestCredentials = do + caCreds <- liftIO $ genCredentials Nothing (0, 24) "CA" + sessionCreds <- liftIO $ genCredentials (Just caCreds) (0, 24) "Session" + pure . tlsCredentials $ sessionCreds :| [caCreds] diff --git a/tests/Test.hs b/tests/Test.hs index 455d5459c7..d68de34aa5 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -8,6 +8,7 @@ import Data.Time.Clock.System import MarkdownTests import MobileTests import ProtocolTests +import RemoteTests import SchemaDump import Test.Hspec import UnliftIO.Temporary (withTempDirectory) @@ -28,6 +29,7 @@ main = do describe "SimpleX chat client" chatTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests + describe "Remote session" remoteTests where testBracket test = do t <- getSystemTime From 91fc238ddc95769ebcb6f32e63e08bb9bd6ada54 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 5 Oct 2023 00:06:23 +0800 Subject: [PATCH 037/294] desktop: libs refactoring (#3169) * desktop: libs refactoring * mac fix * windows fix * .gitignore * unused lines * desktop (windows): adapting Windows build to new libs * removed unused code --------- Co-authored-by: avently --- apps/multiplatform/.gitignore | 1 - .../src/commonMain/cpp/desktop/CMakeLists.txt | 7 +- .../simplex/common/platform/Files.desktop.kt | 2 - .../common/platform/Platform.desktop.kt | 12 +-- apps/multiplatform/desktop/build.gradle.kts | 91 ++++--------------- .../kotlin/chat/simplex/desktop/Main.kt | 42 ++------- scripts/desktop/build-lib-linux.sh | 24 ++++- scripts/desktop/build-lib-mac.sh | 28 ++++-- scripts/desktop/build-lib-windows.sh | 15 ++- scripts/desktop/download-lib-windows.sh | 13 ++- scripts/desktop/make-appimage-linux.sh | 9 +- scripts/desktop/prepare-vlc-linux.sh | 2 +- scripts/desktop/prepare-vlc-mac.sh | 2 +- scripts/desktop/prepare-vlc-windows.sh | 2 +- 14 files changed, 104 insertions(+), 146 deletions(-) diff --git a/apps/multiplatform/.gitignore b/apps/multiplatform/.gitignore index 81d296183d..f30061200c 100644 --- a/apps/multiplatform/.gitignore +++ b/apps/multiplatform/.gitignore @@ -11,7 +11,6 @@ local.properties common/src/commonMain/cpp/android/libs/ common/src/commonMain/cpp/desktop/libs/ -desktop/src/jvmMain/resources/libs/ android/build android/release common/build diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt index eb4794dd6a..059e5af426 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt @@ -54,12 +54,11 @@ add_library( # Sets the name of the library. simplex-api.c) add_library( simplex SHARED IMPORTED ) -# Lib has different name because of version, find it -FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT}) - if(WIN32) + FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT}) set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB}) else() + FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex-chat*.${OS_LIB_EXT}) set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB}) endif() @@ -72,7 +71,7 @@ if(NOT APPLE) else() # Without direct linking it can't find hs_init in linking step add_library( rts SHARED IMPORTED ) - FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/deps/libHSrts*_thr-*.${OS_LIB_EXT}) + FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSrts*_thr-*.${OS_LIB_EXT}) set_target_properties( rts PROPERTIES IMPORTED_LOCATION ${RTSLIB}) target_link_libraries(app-lib rts simplex) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 46124a44fa..9042a62830 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -21,8 +21,6 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir -val vlcDir: File = File(System.getProperty("java.io.tmpdir") + File.separator + "simplex-vlc").also { it.deleteOnExit() } - actual fun desktopOpenDatabaseDir() { if (Desktop.isDesktopSupported()) { try { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index cb4e3acdb4..9217551a8d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -8,12 +8,12 @@ private val unixConfigPath = (System.getenv("XDG_CONFIG_HOME") ?: "$home/.config private val unixDataPath = (System.getenv("XDG_DATA_HOME") ?: "$home/.local/share") + "/simplex" val desktopPlatform = detectDesktopPlatform() -enum class DesktopPlatform(val libPath: String, val libExtension: String, val configPath: String, val dataPath: String) { - LINUX_X86_64("/libs/linux-x86_64", "so", unixConfigPath, unixDataPath), - LINUX_AARCH64("/libs/aarch64", "so", unixConfigPath, unixDataPath), - WINDOWS_X86_64("/libs/windows-x86_64", "dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX"), - MAC_X86_64("/libs/mac-x86_64", "dylib", unixConfigPath, unixDataPath), - MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath); +enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String) { + LINUX_X86_64("so", unixConfigPath, unixDataPath), + LINUX_AARCH64("so", unixConfigPath, unixDataPath), + WINDOWS_X86_64("dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX"), + MAC_X86_64("dylib", unixConfigPath, unixDataPath), + MAC_AARCH64("dylib", unixConfigPath, unixDataPath); fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64 fun isWindows() = this == WINDOWS_X86_64 diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 3da87850d5..a7dab78ee8 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -52,6 +52,7 @@ compose { } //includeAllModules = true outputBaseDir.set(project.file("../release")) + appResourcesRootDir.set(project.file("../build/links")) targetFormats( TargetFormat.Deb, TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Exe //, TargetFormat.AppImage // Gradle doesn't sync on Mac with it @@ -156,11 +157,10 @@ tasks.named("compileJava") { afterEvaluate { tasks.create("cmakeBuildAndCopy") { dependsOn("cmakeBuild") - val copyDetails = mutableMapOf>() doLast { copy { - from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps") - into("src/jvmMain/resources/libs/linux-x86_64") + from("${project(":desktop").buildDir}/cmake/main/linux-amd64") + into("$cppPath/desktop/libs/linux-x86_64") include("*.so*") eachFile { path = name @@ -169,16 +169,8 @@ afterEvaluate { duplicatesStrategy = DuplicatesStrategy.INCLUDE } copy { - val destinationDir = "src/jvmMain/resources/libs/linux-x86_64/vlc" - from("$cppPath/desktop/libs/linux-x86_64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps") - into("src/jvmMain/resources/libs/linux-aarch64") + from("${project(":desktop").buildDir}/cmake/main/linux-aarch64") + into("$cppPath/desktop/libs/linux-aarch64") include("*.so*") eachFile { path = name @@ -187,16 +179,18 @@ afterEvaluate { duplicatesStrategy = DuplicatesStrategy.INCLUDE } copy { - val destinationDir = "src/jvmMain/resources/libs/linux-aarch64/vlc" - from("$cppPath/desktop/libs/linux-aarch64/deps/vlc") - into(destinationDir) + from("${project(":desktop").buildDir}/cmake/main/windows-amd64") + into("$cppPath/desktop/libs/windows-x86_64") + include("*.dll") + eachFile { + path = name + } includeEmptyDirs = false duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) } - copy { - from("${project(":desktop").buildDir}/cmake/main/windows-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") - into("src/jvmMain/resources/libs/windows-x86_64") + copy { + from("${project(":desktop").buildDir}/cmake/main/windows-amd64") + into("../build/links/windows-x64") include("*.dll") eachFile { path = name @@ -205,16 +199,8 @@ afterEvaluate { duplicatesStrategy = DuplicatesStrategy.INCLUDE } copy { - val destinationDir = "src/jvmMain/resources/libs/windows-x86_64/vlc" - from("$cppPath/desktop/libs/windows-x86_64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps") - into("src/jvmMain/resources/libs/mac-x86_64") + from("${project(":desktop").buildDir}/cmake/main/mac-x86_64") + into("$cppPath/desktop/libs/mac-x86_64") include("*.dylib") eachFile { path = name @@ -223,16 +209,8 @@ afterEvaluate { duplicatesStrategy = DuplicatesStrategy.INCLUDE } copy { - val destinationDir = "src/jvmMain/resources/libs/mac-x86_64/vlc" - from("$cppPath/desktop/libs/mac-x86_64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps") - into("src/jvmMain/resources/libs/mac-aarch64") + from("${project(":desktop").buildDir}/cmake/main/mac-aarch64") + into("$cppPath/desktop/libs/mac-aarch64") include("*.dylib") eachFile { path = name @@ -240,39 +218,6 @@ afterEvaluate { includeEmptyDirs = false duplicatesStrategy = DuplicatesStrategy.INCLUDE } - copy { - val destinationDir = "src/jvmMain/resources/libs/mac-aarch64/vlc" - from("$cppPath/desktop/libs/mac-aarch64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - } - afterEvaluate { - doLast { - copyDetails.forEach { (destinationDir, details) -> - details.forEach { detail -> - val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path) - if (target.exists()) { - target.setLastModified(detail.lastModified) - } - } - } - } } } } - -fun CopySpec.copyIfNeeded(destinationDir: String, into: MutableMap>) { - val details = arrayListOf() - eachFile { - val targetFile = File(destinationDir, path) - if (file.lastModified() == targetFile.lastModified() && file.length() == targetFile.length()) { - exclude() - } else { - details.add(this) - } - } - into[destinationDir] = details -} diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index a0be877329..ff92b08ff0 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -18,51 +18,29 @@ fun main() { @Suppress("UnsafeDynamicallyLoadedCode") private fun initHaskell() { - val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs") - copyResources(desktopPlatform.libPath, libsTmpDir.toPath()) - vlcDir.deleteRecursively() - Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING) + val resourcesDir = File(System.getProperty("compose.application.resources.dir")) + val vlcDir = File(resourcesDir.absolutePath + File.separator + "vlc") if (desktopPlatform == DesktopPlatform.WINDOWS_X86_64) { - windowsLoadRequiredLibs(libsTmpDir) + windowsLoadRequiredLibs(resourcesDir, vlcDir) } else { - System.load(File(libsTmpDir, "libapp-lib.${desktopPlatform.libExtension}").absolutePath) + System.load(File(resourcesDir, "libapp-lib.${desktopPlatform.libExtension}").absolutePath) } // No picture without preloading it, only sound. However, with libs from AppImage it works without preloading //val libXcb = "libvlc_xcb_events.so.0.0.0" //System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath) System.setProperty("jna.library.path", vlcDir.absolutePath) //discoverVlcLibs(File(File(vlcDir, "vlc"), "plugins").absolutePath) - - libsTmpDir.deleteRecursively() initHS() } -private fun copyResources(from: String, to: Path) { - val resource = Class.forName("chat.simplex.desktop.MainKt").getResource("")!!.toURI() - val fileSystem = FileSystems.newFileSystem(resource, emptyMap()) - val resPath = fileSystem.getPath(from) - Files.walkFileTree(resPath, object: SimpleFileVisitor() { - override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { - Files.createDirectories(to.resolve(resPath.relativize(dir).toString())) - return FileVisitResult.CONTINUE - } - override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - val dest = to.resolve(resPath.relativize(file).toString()) - Files.copy(file, dest, StandardCopyOption.REPLACE_EXISTING) - // Setting the same time on file as the time set in script that generates VLC libs - if (dest.toString().contains("." + desktopPlatform.libExtension)) { - dest.setLastModifiedTime(FileTime.fromMillis(0)) - } - return FileVisitResult.CONTINUE - } - }) -} - -private fun windowsLoadRequiredLibs(libsTmpDir: File) { +private fun windowsLoadRequiredLibs(libsTmpDir: File, vlcDir: File) { val mainLibs = arrayOf( "libcrypto-3-x64.dll", + "mcfgthread-12.dll", + "libgcc_s_seh-1.dll", + "libstdc++-6.dll", "libffi-8.dll", - "libgmp-10.dll", + "libgmp-10.dll", "libsimplex.dll", "libapp-lib.dll" ) @@ -72,7 +50,7 @@ private fun windowsLoadRequiredLibs(libsTmpDir: File) { val vlcLibs = arrayOf( "libvlccore.dll", "libvlc.dll", - "axvlc.dll", + "axvlc.dll", "npvlc.dll" ) vlcLibs.forEach { diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index 2d9681fc6f..e0ee7e6698 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -1,9 +1,23 @@ #!/bin/bash +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} + OS=linux ARCH=${1:-`uname -a | rev | cut -d' ' -f2 | rev`} GHC_VERSION=9.6.2 +if [ "$ARCH" == "aarch64" ]; then + COMPOSE_ARCH=arm64 +else + COMPOSE_ARCH=x64 +fi + +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" +cd $root_dir BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* rm -rf $BUILD_DIR @@ -11,16 +25,20 @@ cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -flink-rts - cd $BUILD_DIR/build #patchelf --add-needed libHSrts_thr-ghc${GHC_VERSION}.so libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so #patchelf --add-rpath '$ORIGIN' libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so -mkdir deps +mkdir deps 2> /dev/null || true ldd libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so | grep "ghc" | cut -d' ' -f 3 | xargs -I {} cp {} ./deps/ cd - rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +cp -r $BUILD_DIR/build/deps/* apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ scripts/desktop/prepare-vlc-linux.sh + +links_dir=apps/multiplatform/build/links +mkdir -p $links_dir +cd $links_dir +ln -sfT ../../common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ $OS-$COMPOSE_ARCH diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 303e331549..3680a4a2aa 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -4,10 +4,13 @@ set -e OS=mac ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" +COMPOSE_ARCH=$ARCH GHC_VERSION=9.6.2 if [ "$ARCH" == "arm64" ]; then ARCH=aarch64 +else + COMPOSE_ARCH=x64 fi LIB_EXT=dylib @@ -84,30 +87,29 @@ rm deps/`basename $LIB` cd - rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +cp -r $BUILD_DIR/build/deps/* apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) -install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct-*.$LIB_EXT -cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT -chmod 755 deps/libcrypto.1.1.$LIB_EXT -install_name_tool -id "libcrypto.1.1.$LIB_EXT" deps/libcrypto.1.1.$LIB_EXT -install_name_tool -id "libffi.8.$LIB_EXT" deps/libffi.$LIB_EXT +LIBCRYPTO_PATH=$(otool -l libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSdrct-*.$LIB_EXT +cp $LIBCRYPTO_PATH libcrypto.1.1.$LIB_EXT +chmod 755 libcrypto.1.1.$LIB_EXT +install_name_tool -id "libcrypto.1.1.$LIB_EXT" libcrypto.1.1.$LIB_EXT +install_name_tool -id "libffi.8.$LIB_EXT" libffi.$LIB_EXT LIBCRYPTO_PATH=$(otool -l $LIB | grep libcrypto | cut -d' ' -f11) if [ -n "$LIBCRYPTO_PATH" ]; then install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT $LIB fi -LIBCRYPTO_PATH=$(otool -l deps/libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +LIBCRYPTO_PATH=$(otool -l libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) if [ -n "$LIBCRYPTO_PATH" ]; then - install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSsmplxmq*.$LIB_EXT + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsmplxmq*.$LIB_EXT fi for lib in $(find . -type f -name "*.$LIB_EXT"); do @@ -126,3 +128,9 @@ fi cd - scripts/desktop/prepare-vlc-mac.sh + +links_dir=apps/multiplatform/build/links +mkdir -p $links_dir +cd $links_dir +rm macos-$COMPOSE_ARCH 2>/dev/null | true +ln -sf ../../common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ macos-$COMPOSE_ARCH diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index 658324baa8..ef39ef8683 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -8,15 +8,26 @@ function readlink() { root_dir="$(dirname "$(dirname "$(readlink "$0")")")" OS=windows -ARCH=`uname -a | rev | cut -d' ' -f2 | rev` +ARCH="x86_64" JOB_REPO=${1:-$SIMPLEX_CI_REPO_URL} +if [ "$ARCH" == "aarch64" ]; then + COMPOSE_ARCH=arm64 +else + COMPOSE_ARCH=x64 +fi + cd $root_dir rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ scripts/desktop/download-lib-windows.sh $JOB_REPO scripts/desktop/prepare-vlc-windows.sh + +links_dir=apps/multiplatform/build/links +mkdir -p $links_dir +cd $links_dir +rm -rf $OS-$COMPOSE_ARCH +ln -sfT ../../common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ $OS-$COMPOSE_ARCH diff --git a/scripts/desktop/download-lib-windows.sh b/scripts/desktop/download-lib-windows.sh index 14439274c0..945bd7b5e7 100644 --- a/scripts/desktop/download-lib-windows.sh +++ b/scripts/desktop/download-lib-windows.sh @@ -7,7 +7,7 @@ function readlink() { } if [ -z "${1}" ]; then - echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-lib-windows.sh https://something.com/job/something/{windows,windows-8107}" + echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-lib-windows.sh https://something.com/job/something/{master,stable}" exit 1 fi @@ -16,12 +16,15 @@ arch=x86_64 root_dir="$(dirname "$(dirname "$(readlink "$0")")")" output_dir="$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-$arch/" -mkdir -p "$output_dir"/deps 2> /dev/null +mkdir -p "$output_dir" 2> /dev/null curl --location -o libsimplex.zip $job_repo/$arch-linux.$arch-windows:lib:simplex-chat/latest/download/1 && \ $WINDIR\\System32\\tar.exe -xf libsimplex.zip && \ mv libsimplex.dll "$output_dir" && \ -mv libcrypto*.dll "$output_dir/deps" && \ -mv libffi*.dll "$output_dir/deps" && \ -mv libgmp*.dll "$output_dir/deps" && \ +mv libcrypto*.dll "$output_dir" && \ +mv libffi*.dll "$output_dir" && \ +mv libgmp*.dll "$output_dir" && \ +mv mcfgthread*.dll "$output_dir" && \ +mv libgcc_s_seh*.dll "$output_dir" && \ +mv libstdc++*.dll "$output_dir" && \ rm libsimplex.zip diff --git a/scripts/desktop/make-appimage-linux.sh b/scripts/desktop/make-appimage-linux.sh index 35e62481db..9cd6f525f5 100755 --- a/scripts/desktop/make-appimage-linux.sh +++ b/scripts/desktop/make-appimage-linux.sh @@ -11,13 +11,12 @@ multiplatform_dir=$root_dir/apps/multiplatform release_app_dir=$root_dir/apps/multiplatform/release/main/app cd $multiplatform_dir -libcrypto_path=$(ldd common/src/commonMain/cpp/desktop/libs/*/deps/libHSdirect-sqlcipher-*.so | grep libcrypto | cut -d'=' -f 2 | cut -d ' ' -f 2) -trap "rm common/src/commonMain/cpp/desktop/libs/*/deps/`basename $libcrypto_path` 2> /dev/null || true" EXIT -cp $libcrypto_path common/src/commonMain/cpp/desktop/libs/*/deps +libcrypto_path=$(ldd common/src/commonMain/cpp/desktop/libs/*/libHSdirect-sqlcipher-*.so | grep libcrypto | cut -d'=' -f 2 | cut -d ' ' -f 2) +trap "rm common/src/commonMain/cpp/desktop/libs/*/`basename $libcrypto_path` 2> /dev/null || true" EXIT +cp $libcrypto_path common/src/commonMain/cpp/desktop/libs/* ./gradlew createDistributable -rm common/src/commonMain/cpp/desktop/libs/*/deps/`basename $libcrypto_path` -rm desktop/src/jvmMain/resources/libs/*/`basename $libcrypto_path` +rm common/src/commonMain/cpp/desktop/libs/*/`basename $libcrypto_path` rm -rf $release_app_dir/AppDir 2>/dev/null mkdir -p $release_app_dir/AppDir/usr diff --git a/scripts/desktop/prepare-vlc-linux.sh b/scripts/desktop/prepare-vlc-linux.sh index e1cfa7e9fc..a76486150c 100755 --- a/scripts/desktop/prepare-vlc-linux.sh +++ b/scripts/desktop/prepare-vlc-linux.sh @@ -6,7 +6,7 @@ function readlink() { echo "$(cd "$(dirname "$1")"; pwd -P)" } root_dir="$(dirname "$(dirname "$(readlink "$0")")")" -vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-x86_64/deps/vlc +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-x86_64/vlc mkdir $vlc_dir || exit 0 diff --git a/scripts/desktop/prepare-vlc-mac.sh b/scripts/desktop/prepare-vlc-mac.sh index 69644bcc16..25ec1365fc 100755 --- a/scripts/desktop/prepare-vlc-mac.sh +++ b/scripts/desktop/prepare-vlc-mac.sh @@ -16,7 +16,7 @@ function readlink() { } root_dir="$(dirname "$(dirname "$(readlink "$0")")")" -vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-$ARCH/deps/vlc +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-$ARCH/vlc #rm -rf $vlc_dir mkdir -p $vlc_dir/vlc || exit 0 diff --git a/scripts/desktop/prepare-vlc-windows.sh b/scripts/desktop/prepare-vlc-windows.sh index bdb492344a..680fa7b803 100644 --- a/scripts/desktop/prepare-vlc-windows.sh +++ b/scripts/desktop/prepare-vlc-windows.sh @@ -6,7 +6,7 @@ function readlink() { echo "$(cd "$(dirname "$1")"; pwd -P)" } root_dir="$(dirname "$(dirname "$(readlink "$0")")")" -vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64/deps/vlc +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64/vlc rm -rf $vlc_dir mkdir -p $vlc_dir/vlc || exit 0 From 0d8558a6d04688e6ed000ec73b8c1a54ba17508b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 4 Oct 2023 17:45:39 +0100 Subject: [PATCH 038/294] ios: profile names (remove full name) (#3168) * ios: profile names (remove full name) * create/update groups * focus display name --- .../Views/Chat/Group/GroupProfileView.swift | 67 +++- .../Shared/Views/NewChat/AddGroupView.swift | 41 +-- .../Views/Onboarding/CreateProfile.swift | 341 +++++++++++------- .../Views/Onboarding/OnboardingView.swift | 2 +- .../Views/UserSettings/SettingsView.swift | 4 +- .../Views/UserSettings/UserProfile.swift | 51 ++- apps/ios/SimpleXChat/SimpleX.h | 1 + 7 files changed, 315 insertions(+), 192 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 729556e739..6ff3af66a5 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -9,6 +9,18 @@ import SwiftUI import SimpleXChat +enum GroupProfileAlert: Identifiable { + case saveError(err: String) + case invalidName(validName: String) + + var id: String { + switch self { + case let .saveError(err): return "saveError \(err)" + case let .invalidName(validName): return "invalidName \(validName)" + } + } +} + struct GroupProfileView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @@ -18,8 +30,7 @@ struct GroupProfileView: View { @State private var showImagePicker = false @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil - @State private var showSaveErrorAlert = false - @State private var saveGroupError: String? = nil + @State private var alert: GroupProfileAlert? @FocusState private var focusDisplayName var body: some View { @@ -47,20 +58,29 @@ struct GroupProfileView: View { .frame(maxWidth: .infinity, alignment: .center) VStack(alignment: .leading) { - ZStack(alignment: .leading) { - if !validDisplayName(groupProfile.displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.bottom, 10) + ZStack(alignment: .topLeading) { + if !validNewProfileName() { + Button { + alert = .invalidName(validName: mkValidName(groupProfile.displayName)) + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } profileNameTextEdit("Group display name", $groupProfile.displayName) .focused($focusDisplayName) } - profileNameTextEdit("Group full name (optional)", $groupProfile.fullName) + .padding(.bottom) + let fullName = groupInfo.groupProfile.fullName + if fullName != "" && fullName != groupProfile.displayName { + profileNameTextEdit("Group full name (optional)", $groupProfile.fullName) + .padding(.bottom) + } HStack(spacing: 20) { Button("Cancel") { dismiss() } Button("Save group profile") { saveProfile() } - .disabled(groupProfile.displayName == "" || !validDisplayName(groupProfile.displayName)) + .disabled(groupProfile.displayName == "" || !validNewProfileName()) } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) @@ -99,27 +119,35 @@ struct GroupProfileView: View { focusDisplayName = true } } - .alert(isPresented: $showSaveErrorAlert) { - Alert( - title: Text("Error saving group profile"), - message: Text("\(saveGroupError ?? "Unexpected error")") - ) + .alert(item: $alert) { a in + switch a { + case let .saveError(err): + return Alert( + title: Text("Error saving group profile"), + message: Text(err) + ) + case let .invalidName(name): + return createInvalidNameAlert(name, $groupProfile.displayName) + } } .contentShape(Rectangle()) .onTapGesture { hideKeyboard() } } + private func validNewProfileName() -> Bool { + groupProfile.displayName == groupInfo.groupProfile.displayName + || validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces)) + } + func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View { TextField(label, text: name) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.bottom) - .padding(.leading, 28) + .padding(.leading, 32) } func saveProfile() { Task { do { + groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces) let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) await MainActor.run { groupInfo = gInfo @@ -128,8 +156,7 @@ struct GroupProfileView: View { } } catch let error { let err = responseError(error) - saveGroupError = err - showSaveErrorAlert = true + alert = .saveError(err: err) logger.error("GroupProfile apiUpdateGroup error: \(err)") } } diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 8df37bb560..5cea52cc8b 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -16,11 +16,11 @@ struct AddGroupView: View { @State private var groupInfo: GroupInfo? @State private var profile = GroupProfile(displayName: "", fullName: "") @FocusState private var focusDisplayName - @FocusState private var focusFullName @State private var showChooseSource = false @State private var showImagePicker = false @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil + @State private var showInvalidNameAlert = false var body: some View { if let chat = chat, let groupInfo = groupInfo { @@ -76,26 +76,24 @@ struct AddGroupView: View { .padding(.bottom, 4) ZStack(alignment: .topLeading) { - if !validDisplayName(profile.displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.top, 4) + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidNameAlert = true + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } - textField("Group display name", text: $profile.displayName) + textField("Enter group name…", text: $profile.displayName) .focused($focusDisplayName) - .submitLabel(.next) + .submitLabel(.go) .onSubmit { - if canCreateProfile() { focusFullName = true } - else { focusDisplayName = true } + if canCreateProfile() { createGroup() } } } - textField("Group full name (optional)", text: $profile.fullName) - .focused($focusFullName) - .submitLabel(.go) - .onSubmit { - if canCreateProfile() { createGroup() } - else { focusFullName = true } - } + .padding(.bottom) Spacer() @@ -133,6 +131,9 @@ struct AddGroupView: View { didSelectItem in showImagePicker = false } } + .alert(isPresented: $showInvalidNameAlert) { + createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName) + } .onChange(of: chosenImage) { image in if let image = image { profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) @@ -146,15 +147,13 @@ struct AddGroupView: View { func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { TextField(placeholder, text: text) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.leading, 28) - .padding(.bottom) + .padding(.leading, 32) } func createGroup() { hideKeyboard() do { + profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) let gInfo = try apiNewGroup(profile) Task { let groupMembers = await apiListMembers(gInfo.groupId) @@ -180,7 +179,7 @@ struct AddGroupView: View { } func canCreateProfile() -> Bool { - profile.displayName != "" && validDisplayName(profile.displayName) + profile.displayName != "" && validDisplayName(profile.displayName.trimmingCharacters(in: .whitespaces)) } } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index d05ac44588..f5db37dacf 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -9,175 +9,244 @@ import SwiftUI import SimpleXChat +enum UserProfileAlert: Identifiable { + case duplicateUserError + case createUserError(error: LocalizedStringKey) + case invalidNameError(validName: String) + + var id: String { + switch self { + case .duplicateUserError: return "duplicateUserError" + case .createUserError: return "createUserError" + case let .invalidNameError(validName): return "invalidNameError \(validName)" + } + } +} + struct CreateProfile: View { + @Environment(\.dismiss) var dismiss + @State private var displayName: String = "" + @FocusState private var focusDisplayName + @State private var alert: UserProfileAlert? + + var body: some View { + List { + Section { + TextField("Enter your name…", text: $displayName) + .focused($focusDisplayName) + Button { + createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss) + } label: { + Label("Create profile", systemImage: "checkmark") + } + .disabled(!canCreateProfile(displayName)) + } header: { + HStack { + Text("Your profile") + let name = displayName.trimmingCharacters(in: .whitespaces) + let validName = mkValidName(name) + if name != validName { + Spacer() + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + .onTapGesture { + alert = .invalidNameError(validName: validName) + } + } + } + .frame(height: 20) + } footer: { + VStack(alignment: .leading, spacing: 8) { + Text("Your profile, contacts and delivered messages are stored on your device.") + Text("The profile is only shared with your contacts.") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .navigationTitle("Create your profile") + .alert(item: $alert) { a in userProfileAlert(a, $displayName) } + .onAppear() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + focusDisplayName = true + } + } + .keyboardPadding() + } +} + +struct CreateFirstProfile: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss @State private var displayName: String = "" - @State private var fullName: String = "" @FocusState private var focusDisplayName - @FocusState private var focusFullName - @State private var alert: CreateProfileAlert? - - private enum CreateProfileAlert: Identifiable { - case duplicateUserError - case createUserError(error: LocalizedStringKey) - - var id: String { - switch self { - case .duplicateUserError: return "duplicateUserError" - case .createUserError: return "createUserError" - } - } - } var body: some View { VStack(alignment: .leading) { - Text("Create your profile") - .font(.largeTitle) - .bold() - .padding(.bottom, 4) - .frame(maxWidth: .infinity) - Text("Your profile, contacts and delivered messages are stored on your device.") - .padding(.bottom, 4) - Text("The profile is only shared with your contacts.") - .padding(.bottom) + Group { + Text("Create your profile") + .font(.largeTitle) + .bold() + Text("Your profile, contacts and delivered messages are stored on your device.") + .foregroundColor(.secondary) + Text("The profile is only shared with your contacts.") + .foregroundColor(.secondary) + .padding(.bottom) + } + .padding(.bottom) + ZStack(alignment: .topLeading) { - if !validDisplayName(displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.top, 4) + let name = displayName.trimmingCharacters(in: .whitespaces) + let validName = mkValidName(name) + if name != validName { + Button { + showAlert(.invalidNameError(validName: validName)) + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } - textField("Display name", text: $displayName) + TextField("Enter your name…", text: $displayName) .focused($focusDisplayName) - .submitLabel(.next) - .onSubmit { - if canCreateProfile() { focusFullName = true } - else { focusDisplayName = true } - } + .padding(.leading, 32) } - textField("Full name (optional)", text: $fullName) - .focused($focusFullName) - .submitLabel(.go) - .onSubmit { - if canCreateProfile() { createProfile() } - else { focusFullName = true } - } - + .padding(.bottom) Spacer() - - HStack { - if m.users.isEmpty { - Button { - hideKeyboard() - withAnimation { - m.onboardingStage = .step1_SimpleXInfo - } - } label: { - HStack { - Image(systemName: "lessthan") - Text("About SimpleX") - } - } - } - - Spacer() - - HStack { - Button { - createProfile() - } label: { - Text("Create") - Image(systemName: "greaterthan") - } - .disabled(!canCreateProfile()) - } - } + onboardingButtons() } .onAppear() { focusDisplayName = true setLastVersionDefault() } - .alert(item: $alert) { a in - switch a { - case .duplicateUserError: return duplicateUserAlert - case let .createUserError(err): return creatUserErrorAlert(err) - } - } .padding() + .frame(maxWidth: .infinity, alignment: .leading) .keyboardPadding() } - func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { - TextField(placeholder, text: text) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.leading, 28) - .padding(.bottom) - } - - func createProfile() { - hideKeyboard() - let profile = Profile( - displayName: displayName, - fullName: fullName - ) - do { - m.currentUser = try apiCreateActiveUser(profile) - if m.users.isEmpty { - try startChat() + func onboardingButtons() -> some View { + HStack { + Button { + hideKeyboard() withAnimation { - onboardingStageDefault.set(.step3_CreateSimpleXAddress) - m.onboardingStage = .step3_CreateSimpleXAddress + m.onboardingStage = .step1_SimpleXInfo } - } else { - onboardingStageDefault.set(.onboardingComplete) - m.onboardingStage = .onboardingComplete - dismiss() - m.users = try listUsers() - try getUserChatData() - } - } catch let error { - switch error as? ChatResponse { - case .chatCmdError(_, .errorStore(.duplicateName)), - .chatCmdError(_, .error(.userExists)): - if m.currentUser == nil { - AlertManager.shared.showAlert(duplicateUserAlert) - } else { - alert = .duplicateUserError - } - default: - let err: LocalizedStringKey = "Error: \(responseError(error))" - if m.currentUser == nil { - AlertManager.shared.showAlert(creatUserErrorAlert(err)) - } else { - alert = .createUserError(error: err) + } label: { + HStack { + Image(systemName: "lessthan") + Text("About SimpleX") } } - logger.error("Failed to create user or start chat: \(responseError(error))") + + Spacer() + + Button { + createProfile(displayName, showAlert: showAlert, dismiss: dismiss) + } label: { + HStack { + Text("Create") + Image(systemName: "greaterthan") + } + } + .disabled(!canCreateProfile(displayName)) } } - func canCreateProfile() -> Bool { - displayName != "" && validDisplayName(displayName) - } - - private var duplicateUserAlert: Alert { - Alert( - title: Text("Duplicate display name!"), - message: Text("You already have a chat profile with the same display name. Please choose another name.") - ) - } - - private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert { - Alert( - title: Text("Error creating profile!"), - message: Text(err) - ) + private func showAlert(_ alert: UserProfileAlert) { + AlertManager.shared.showAlert(userProfileAlert(alert, $displayName)) } } +private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) { + hideKeyboard() + let profile = Profile( + displayName: displayName.trimmingCharacters(in: .whitespaces), + fullName: "" + ) + let m = ChatModel.shared + do { + m.currentUser = try apiCreateActiveUser(profile) + if m.users.isEmpty { + try startChat() + withAnimation { + onboardingStageDefault.set(.step3_CreateSimpleXAddress) + m.onboardingStage = .step3_CreateSimpleXAddress + } + } else { + onboardingStageDefault.set(.onboardingComplete) + m.onboardingStage = .onboardingComplete + dismiss() + m.users = try listUsers() + try getUserChatData() + } + } catch let error { + switch error as? ChatResponse { + case .chatCmdError(_, .errorStore(.duplicateName)), + .chatCmdError(_, .error(.userExists)): + if m.currentUser == nil { + AlertManager.shared.showAlert(duplicateUserAlert) + } else { + showAlert(.duplicateUserError) + } + default: + let err: LocalizedStringKey = "Error: \(responseError(error))" + if m.currentUser == nil { + AlertManager.shared.showAlert(creatUserErrorAlert(err)) + } else { + showAlert(.createUserError(error: err)) + } + } + logger.error("Failed to create user or start chat: \(responseError(error))") + } +} + +private func canCreateProfile(_ displayName: String) -> Bool { + let name = displayName.trimmingCharacters(in: .whitespaces) + return name != "" && mkValidName(name) == name +} + +func userProfileAlert(_ alert: UserProfileAlert, _ displayName: Binding) -> Alert { + switch alert { + case .duplicateUserError: return duplicateUserAlert + case let .createUserError(err): return creatUserErrorAlert(err) + case let .invalidNameError(name): return createInvalidNameAlert(name, displayName) + } +} + +private var duplicateUserAlert: Alert { + Alert( + title: Text("Duplicate display name!"), + message: Text("You already have a chat profile with the same display name. Please choose another name.") + ) +} + +private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert { + Alert( + title: Text("Error creating profile!"), + message: Text(err) + ) +} + +func createInvalidNameAlert(_ name: String, _ displayName: Binding) -> Alert { + name == "" + ? Alert(title: Text("Invalid name!")) + : Alert( + title: Text("Invalid name!"), + message: Text("Correct name to \(name)?"), + primaryButton: .default( + Text("Ok"), + action: { displayName.wrappedValue = name } + ), + secondaryButton: .cancel() + ) +} + func validDisplayName(_ name: String) -> Bool { - name.firstIndex(of: " ") == nil && name.first != "@" && name.first != "#" + mkValidName(name.trimmingCharacters(in: .whitespaces)) == name +} + +func mkValidName(_ s: String) -> String { + var c = s.cString(using: .utf8)! + return fromCString(chat_valid_name(&c)!) } struct CreateProfile_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index b0734be642..438491b5f1 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -14,7 +14,7 @@ struct OnboardingView: View { var body: some View { switch onboarding { case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) - case .step2_CreateProfile: CreateProfile() + case .step2_CreateProfile: CreateFirstProfile() case .step3_CreateSimpleXAddress: CreateSimpleXAddress() case .step4_SetNotificationsMode: SetNotificationsMode() case .onboardingComplete: EmptyView() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index d6681a51cd..f076a4eb7d 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -381,7 +381,9 @@ struct ProfilePreview: View { Text(profileOf.displayName) .fontWeight(.bold) .font(.title2) - Text(profileOf.fullName) + if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { + Text(profileOf.fullName) + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index f38dc593a6..88be0b6a7f 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -17,6 +17,8 @@ struct UserProfile: View { @State private var showImagePicker = false @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil + @State private var alert: UserProfileAlert? + @FocusState private var focusDisplayName var body: some View { let user: User = chatModel.currentUser! @@ -47,18 +49,27 @@ struct UserProfile: View { VStack(alignment: .leading) { ZStack(alignment: .leading) { - if !validDisplayName(profile.displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.bottom, 10) + if !validNewProfileName(user) { + Button { + alert = .invalidNameError(validName: mkValidName(profile.displayName)) + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } - profileNameTextEdit("Display name", $profile.displayName) + profileNameTextEdit("Profile name", $profile.displayName) + .focused($focusDisplayName) + } + .padding(.bottom) + if showFullName(user) { + profileNameTextEdit("Full name (optional)", $profile.fullName) + .padding(.bottom) } - profileNameTextEdit("Full name (optional)", $profile.fullName) HStack(spacing: 20) { Button("Cancel") { editProfile = false } Button("Save (and notify contacts)") { saveProfile() } - .disabled(profile.displayName == "" || !validDisplayName(profile.displayName)) + .disabled(!canSaveProfile(user)) } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) @@ -74,11 +85,14 @@ struct UserProfile: View { .frame(maxWidth: .infinity, alignment: .center) VStack(alignment: .leading) { - profileNameView("Display name:", user.profile.displayName) - profileNameView("Full name:", user.profile.fullName) + profileNameView("Profile name:", user.profile.displayName) + if showFullName(user) { + profileNameView("Full name:", user.profile.fullName) + } Button("Edit") { profile = fromLocalProfile(user.profile) editProfile = true + focusDisplayName = true } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) @@ -117,14 +131,12 @@ struct UserProfile: View { profile.image = nil } } + .alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) } } func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View { TextField(label, text: name) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.bottom) - .padding(.leading, 28) + .padding(.leading, 32) } func profileNameView(_ label: LocalizedStringKey, _ name: String) -> some View { @@ -141,9 +153,22 @@ struct UserProfile: View { showChooseSource = true } + private func validNewProfileName(_ user: User) -> Bool { + profile.displayName == user.profile.displayName || validDisplayName(profile.displayName.trimmingCharacters(in: .whitespaces)) + } + + private func showFullName(_ user: User) -> Bool { + user.profile.fullName != "" && user.profile.fullName != user.profile.displayName + } + + private func canSaveProfile(_ user: User) -> Bool { + profile.displayName != "" && validNewProfileName(user) + } + func saveProfile() { Task { do { + profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) if let (newProfile, _) = try await apiUpdateProfile(profile: profile) { DispatchQueue.main.async { chatModel.updateCurrentUser(newProfile) diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 67c2fa728c..9db3f06ae7 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -23,6 +23,7 @@ extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); extern char *chat_parse_markdown(char *str); extern char *chat_parse_server(char *str); extern char *chat_password_hash(char *pwd, char *salt); +extern char *chat_valid_name(char *name); extern char *chat_encrypt_media(char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); From 303d0eedf5346ccfabfb4137c1c981d184f511f9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 4 Oct 2023 20:58:22 +0400 Subject: [PATCH 039/294] core: merge new contacts with existing contacts and group members (#3173) --- docs/rfcs/2023-09-29-merge-scenarios.md | 99 +++++ simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 272 ++++++++++---- src/Simplex/Chat/Controller.hs | 8 +- .../Migrations/M20231002_conn_initiated.hs | 28 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 4 + src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 41 ++- src/Simplex/Chat/Store/Groups.hs | 290 +++++++++------ src/Simplex/Chat/Store/Messages.hs | 2 +- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 4 +- src/Simplex/Chat/Store/Shared.hs | 14 +- src/Simplex/Chat/Types.hs | 19 +- src/Simplex/Chat/View.hs | 16 +- tests/ChatTests/Direct.hs | 132 +++++-- tests/ChatTests/Groups.hs | 347 +++++++++++++++++- tests/ChatTests/Profiles.hs | 34 ++ tests/ChatTests/Utils.hs | 35 +- 19 files changed, 1087 insertions(+), 265 deletions(-) create mode 100644 docs/rfcs/2023-09-29-merge-scenarios.md create mode 100644 src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs diff --git a/docs/rfcs/2023-09-29-merge-scenarios.md b/docs/rfcs/2023-09-29-merge-scenarios.md new file mode 100644 index 0000000000..42a7425f05 --- /dev/null +++ b/docs/rfcs/2023-09-29-merge-scenarios.md @@ -0,0 +1,99 @@ +# Merge scenarios + +## Problem + +Chat client allows multiple contact and group members records referring to the same "identity" be disassociated from one another. In some cases initially there is knowledge about the fact, and in some other cases it could be established via probe mechanism. + +There are cases already addressing this problem: +- Contact merge or contact and group member merge (depending on creation of direct connection between members) when new member joins group, via probe mechanism. +- Contact merge when connecting via group link. +- Contact and group member association when sending direct message to a new contact. +- Existing / deleted contact being preserved when receiving invitation direct message (XGrpDirectInv) from a group member. +- Repeat contact requests to the already connected contact being prohibited; repeat contact requests being squashed on the receiving side (XContactId mechanism). + +Cases ignoring this problem: +- Duplicate contacts on repeat connections via invitation links. +- Duplicate contacts on repeat connection via contact request, when the existing contact was not created via a contact request to the same address (it could be created via any other means - via invitation link, via request to an old address, via group member). +- Contact and group member records (possibly for many groups) not being merged when contact connects and group member records already exist. +- Group member records in different groups not being merged if contact doesn't exist. +- Duplicate contacts, or contact and group member records not being merged when connecting via contact address present in contact/group member profile. + +This problem is a direct consequence of lack of user identity in the platform, and in some cases we even consider it a feature. For example, duplicate contacts via repeat connections can be used for having conversation scopes. Though in general it seems to bring more confusion. It also limits some interactions, such as sending direct message to group members, or viewing a list of groups contact is member of (not implemented). + +On the other hand, solving all these cases reduces the privacy of the main profile, since the client cooperates with other probing clients blindly. To keep this property, we can add an opt-out "Merge contacts" client setting which would affect existing and new probe mechanisms. (It would act as an Incognito mode currently does - launch probes w/t launching probe hashes, and never confirm received probes) We can also ignore it, since this can be worked around via Incognito profiles or multiple user profiles. + +There is one more problem that could be addressed in the same scope - repeat group join via group link fails if the contact with host wasn't deleted. This happens due to group links re-using contact address machinery together with prohibiting repeat connections. This could be treated in a similar way to how it is treated for contact addresses: instead of simply prohibiting repeat connection, if group exists, it would be opened; if group doesn't exist, client would send host a request to re-invite them. + +## Solution + +### Duplicate contacts on repeat connections via invitation links + +Can be solved by probing contacts. + +Check connection with oneself and ask for confirmation. + +### Duplicate contacts on repeat connection via contact request + +Can be solved by probing contacts. + +Special case - records can be directly associated w/t probing when address is known in a contact / group member profile, see below. + +### Contact and group member records not merged when contact connects and group member records already exist + +Can be solved by probing group members. + +Send probe hashes to all viable group members? Or should group member records already have been merged between each other? (see below) + +In all cases above - who should initiate probing? It doesn't matter much, but possibly the contact that started connection. + +### Group member records in different groups not being merged if contact doesn't exist + +Currently multiple group members share the same "identity" by being associated to the same contact. However, it is allowed to have a group member record not associated contact. It can happen if group member's associated contact is deleted, or, with the latest changes that enable skipping creation of direct connections between group members, it could never exist in the first place. + +Can be solved by probing group members, merge could be done in one of the following ways: +- Have surrogate contact records always associated to group member records, not available for use as regular contacts and using group member connections for probing. +- Merge on the level of contact profiles. + +The latter seems more straightforward. + +Some more factors to consider: +- Group member record may have both associated contact to probe via, and matching group member records in other groups. +- Matching group member records in other groups may have associated contact records, which in turn may have associated contact records different from contact record associated to group member in question. +- Client to which probes are sent may have contact record deleted, but have group member connection - in this case merge would be possible only if probe is sent to group member. +- Member connections shouldn't be merged, because generally they're established via hosts, and hosts of different groups may have had different level of trust. + +Probably the solution is to: +- send probe to associated contact if it exists (implemented currently) +- if associated contact doesn't exist send probe to group member (not implemented?) +- send probe hashes to all matching contacts (implemented) +- send probe hashes to all matching group members in other groups that don't have associated contact records (not implemented) +- merge all contacts that confirmed (currently only the first confirming contact is merged) +- merge all group members that confirmed (not implemented, merge profiles?) + +Check: if both group member and associated confirm probe, will they be properly merged? + +### Connecting via profile address + +Having contact address in profile is not proof that it belongs that user (malicious client can put arbitrary address in profile), so it shouldn't be used to directly associate contact or group member records. + +When connecting via contact address, having it associated with a contact record can be used as a sufficient condition to send probe hash, even if profile doesn't match. (index on contact_profiles.contact_link, lookup contact by contact_profile_id) + +To consider: + +Currently group member address is shown even if direct messages are prohibited in group. There're two reasons this preference is usually enabled in a group: +- to prevent abuse (in public groups), +- to prevent members from direct communication. + +Member address being shown regardless of this preference undermines the second use case. + +### Repeat group join via group link + +**If group still exists:** + +Open group. + +How to check for group existence? Currently we save group_link_id on host contact's connection. It may have been deleted by the time of repeated connection via group link. Probably we should also save group_link_id or even the full contact address on the group record itself. + +**If group doesn't exist:** + +For group links allow repeat contact request even if host contact exists (unlike for regular contact requests). diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 0b613310f7..30c4c62dc1 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -114,6 +114,7 @@ library Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes Simplex.Chat.Migrations.M20230926_contact_status + Simplex.Chat.Migrations.M20231002_conn_initiated Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5d370ee651..32d59c9cc5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -39,6 +39,7 @@ import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, foldl', isSuffixOf, partition, sortOn) +import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty, nonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -212,7 +213,8 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + contactMergeEnabled <- newTVarIO True + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile, contactMergeEnabled} where configServers :: DefaultAgentServers configServers = @@ -489,6 +491,9 @@ processChatCommand = \case APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ + SetContactMergeEnabled onOff -> do + asks contactMergeEnabled >>= atomically . (`writeTVar` onOff) + ok_ APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do ts <- liftIO getCurrentTime @@ -2667,6 +2672,7 @@ cleanupManager = do forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay cleanupMessages `catchChatError` (toView . CRChatError Nothing) + cleanupProbes `catchChatError` (toView . CRChatError Nothing) liftIO $ threadDelay' $ diffToMicroseconds interval where runWithoutInitialDelay cleanupInterval = flip catchChatError (toView . CRChatError Nothing) $ do @@ -2694,6 +2700,10 @@ cleanupManager = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (- (30 * nominalDay)) ts withStoreCtx' (Just "cleanupManager, deleteOldMessages") (`deleteOldMessages` cutoffTs) + cleanupProbes = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (- (14 * nominalDay)) ts + withStore' (`deleteOldProbes` cutoffTs) startProximateTimedItemThread :: ChatMonad m => User -> (ChatRef, ChatItemId) -> UTCTime -> m () startProximateTimedItemThread user itemRef deleteAt = do @@ -2976,7 +2986,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> Nothing processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, groupLinkId, customUserProfileId, connectionCode} = \case + processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of CONF confId _ connInfo -> do -- [incognito] send saved profile @@ -3040,9 +3050,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XInfo p -> xInfo ct' p XDirectDel -> xDirectDel ct' msg msgMeta XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta - XInfoProbe probe -> xInfoProbe (CGMContact ct') probe - XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash - XInfoProbeOk probe -> xInfoProbeOk ct' probe + XInfoProbe probe -> xInfoProbe (COMContact ct') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMContact ct') probe XCallInv callId invitation -> xCallInv ct' callId invitation msg msgMeta XCallOffer callId offer -> xCallOffer ct' callId offer msg msgMeta XCallAnswer callId answer -> xCallAnswer ct' callId answer msg msgMeta @@ -3095,7 +3105,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do whenUserNtfs user $ do setActive $ ActiveC c showToast (c <> "> ") "connected" - forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct + when (contactConnInitiated conn) $ do + probeMatchingContactsAndMembers ct (contactConnIncognito ct) + withStore' $ \db -> resetContactConnInitiated db user conn forM_ viaUserContactLink $ \userContactLinkId -> withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case Just (UserContactLink {autoAccept = Just AutoAccept {autoReply = mc_}}, groupId_, gLinkMemRole) -> do @@ -3113,7 +3125,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito SENT msgId -> do sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId @@ -3281,12 +3293,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership - when (memberCategory m == GCPreMember) $ probeMatchingMemberContact gInfo m connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingMemberContact m connectedIncognito Just ct@Contact {activeConn = Connection {connStatus}} -> when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do @@ -3314,9 +3326,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XGrpDel -> xGrpDel gInfo m' msg msgMeta XGrpInfo p' -> xGrpInfo gInfo m' p' msg msgMeta XGrpDirectInv connReq mContent_ -> canSend m' $ xGrpDirectInv gInfo m' conn' connReq mContent_ msg msgMeta - XInfoProbe probe -> xInfoProbe (CGMGroupMember gInfo m') probe - -- XInfoProbeCheck -- TODO merge members? - -- XInfoProbeOk -- TODO merge members? + XInfoProbe probe -> xInfoProbe (COMGroupMember m') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo @@ -3679,45 +3691,56 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" - probeMatchingContacts :: Contact -> IncognitoEnabled -> m () - probeMatchingContacts ct connectedIncognito = do + probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> m () + probeMatchingContactsAndMembers ct connectedIncognito = do gVar <- asks idsDrg - if connectedIncognito - then sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) - else do - (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId (CGMContact ct) + contactMerge <- readTVarIO =<< asks contactMergeEnabled + if contactMerge && not connectedIncognito + then do + (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId (COMContact ct) + -- ! when making changes to probe-and-merge mechanism, + -- ! test scenario in which recipient receives probe after probe hashes (not covered in tests): + -- sendProbe -> sendProbeHashes (currently) + -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - cs <- withStore' $ \db -> getMatchingContacts db user ct - sendProbeHashes cs probe probeId + cs <- map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db user ct) + sendProbeHashes (cs <> ms) probe probeId + else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () sendProbe probe = void . sendDirectContactMessage ct $ XInfoProbe probe - probeMatchingMemberContact :: GroupInfo -> GroupMember -> IncognitoEnabled -> m () - probeMatchingMemberContact _ GroupMember {activeConn = Nothing} _ = pure () - probeMatchingMemberContact g m@GroupMember {groupId, activeConn = Just conn} connectedIncognito = do + probeMatchingMemberContact :: GroupMember -> IncognitoEnabled -> m () + probeMatchingMemberContact GroupMember {activeConn = Nothing} _ = pure () + probeMatchingMemberContact m@GroupMember {groupId, activeConn = Just conn} connectedIncognito = do gVar <- asks idsDrg - if connectedIncognito - then sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) - else do - (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ CGMGroupMember g m + contactMerge <- readTVarIO =<< asks contactMergeEnabled + if contactMerge && not connectedIncognito + then do + (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ COMGroupMember m sendProbe probe - cs <- withStore' $ \db -> getMatchingMemberContacts db user m + cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db user m) sendProbeHashes cs probe probeId + else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () sendProbe probe = void $ sendDirectMessage conn (XInfoProbe probe) (GroupId groupId) - -- TODO currently we only send probe hashes to contacts - sendProbeHashes :: [Contact] -> Probe -> Int64 -> m () - sendProbeHashes cs probe probeId = - forM_ cs $ \c -> sendProbeHash c `catchChatError` \_ -> pure () + sendProbeHashes :: [ContactOrMember] -> Probe -> Int64 -> m () + sendProbeHashes cgms probe probeId = + forM_ cgms $ \cgm -> sendProbeHash cgm `catchChatError` \_ -> pure () where probeHash = ProbeHash $ C.sha256Hash (unProbe probe) - sendProbeHash :: Contact -> m () - sendProbeHash c = do + sendProbeHash :: ContactOrMember -> m () + sendProbeHash cgm@(COMContact c) = do void . sendDirectContactMessage c $ XInfoProbeCheck probeHash - withStore' $ \db -> createSentProbeHash db userId probeId $ CGMContact c + withStore' $ \db -> createSentProbeHash db userId probeId cgm + sendProbeHash (COMGroupMember GroupMember {activeConn = Nothing}) = pure () + sendProbeHash cgm@(COMGroupMember m@GroupMember {groupId, activeConn = Just conn}) = + when (memberCurrent m) $ do + void $ sendDirectMessage conn (XInfoProbeCheck probeHash) (GroupId groupId) + withStore' $ \db -> createSentProbeHash db userId probeId cgm messageWarning :: Text -> m () messageWarning = toView . CRMessageError user "warning" @@ -4303,48 +4326,100 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (_, param) = groupFeatureState p createInternalChatItem user (CDGroupRcv g m) (CIRcvGroupFeature (toGroupFeature f) (toGroupPreference p) param) Nothing - xInfoProbe :: ContactOrGroupMember -> Probe -> m () - xInfoProbe cgm2 probe = + xInfoProbe :: ContactOrMember -> Probe -> m () + xInfoProbe cgm2 probe = do + contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito - unless (contactOrGroupMemberIncognito cgm2) $ do - r <- withStore' $ \db -> matchReceivedProbe db user cgm2 probe - forM_ r $ \case - CGMContact c1 -> probeMatch c1 cgm2 probe - CGMGroupMember _ _ -> messageWarning "xInfoProbe ignored: matched member (no probe hashes sent to members)" + when (contactMerge && not (contactOrMemberIncognito cgm2)) $ do + cgm1s <- withStore' $ \db -> matchReceivedProbe db user cgm2 probe + let cgm1s' = filter (not . contactOrMemberIncognito) cgm1s + probeMatches cgm1s' cgm2 + where + probeMatches :: [ContactOrMember] -> ContactOrMember -> m () + probeMatches [] _ = pure () + probeMatches (cgm1' : cgm1s') cgm2' = do + cgm2''_ <- probeMatch cgm1' cgm2' probe `catchChatError` \_ -> pure (Just cgm2') + let cgm2'' = fromMaybe cgm2' cgm2''_ + probeMatches cgm1s' cgm2'' - -- TODO currently we send probe hashes only to contacts - xInfoProbeCheck :: Contact -> ProbeHash -> m () - xInfoProbeCheck c1 probeHash = + xInfoProbeCheck :: ContactOrMember -> ProbeHash -> m () + xInfoProbeCheck cgm1 probeHash = do + contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito - unless (contactConnIncognito c1) $ do - r <- withStore' $ \db -> matchReceivedProbeHash db user (CGMContact c1) probeHash - forM_ r . uncurry $ probeMatch c1 + when (contactMerge && not (contactOrMemberIncognito cgm1)) $ do + cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db user cgm1 probeHash + forM_ cgm2Probe_ $ \(cgm2, probe) -> + unless (contactOrMemberIncognito cgm2) $ + void $ probeMatch cgm1 cgm2 probe - probeMatch :: Contact -> ContactOrGroupMember -> Probe -> m () - probeMatch c1@Contact {contactId = cId1, profile = p1} cgm2 probe = - case cgm2 of - CGMContact c2@Contact {contactId = cId2, profile = p2} - | cId1 /= cId2 && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - mergeContacts c1 c2 - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" - CGMGroupMember g m2@GroupMember {memberProfile = p2, memberContactId} - | isNothing memberContactId && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - connectContactToMember c1 g m2 - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" + probeMatch :: ContactOrMember -> ContactOrMember -> Probe -> m (Maybe ContactOrMember) + probeMatch cgm1 cgm2 probe = + case cgm1 of + COMContact c1@Contact {contactId = cId1, profile = p1} -> + case cgm2 of + COMContact c2@Contact {contactId = cId2, profile = p2} + | cId1 /= cId2 && profilesMatch p1 p2 -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + COMContact <$$> mergeContacts c1 c2 + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" >> pure Nothing + COMGroupMember m2@GroupMember {memberProfile = p2, memberContactId} + | profilesMatch p1 p2 -> case memberContactId of + Nothing -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + COMContact <$$> associateMemberAndContact c1 m2 + Just mCtId + | mCtId /= cId1 -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + mCt <- withStore $ \db -> getContact db user mCtId + COMContact <$$> mergeContacts c1 mCt + | otherwise -> messageWarning "probeMatch ignored: same contact id" >> pure Nothing + | otherwise -> messageWarning "probeMatch ignored: profiles don't match" >> pure Nothing + COMGroupMember GroupMember {activeConn = Nothing} -> pure Nothing + COMGroupMember m1@GroupMember {groupId, memberProfile = p1, memberContactId, activeConn = Just conn} -> + case cgm2 of + COMContact c2@Contact {contactId = cId2, profile = p2} + | memberCurrent m1 && profilesMatch p1 p2 -> case memberContactId of + Nothing -> do + void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) + COMContact <$$> associateMemberAndContact c2 m1 + Just mCtId + | mCtId /= cId2 -> do + void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) + mCt <- withStore $ \db -> getContact db user mCtId + COMContact <$$> mergeContacts c2 mCt + | otherwise -> messageWarning "probeMatch ignored: same contact id" >> pure Nothing + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member not current" >> pure Nothing + COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing - -- TODO currently we send probe hashes only to contacts - xInfoProbeOk :: Contact -> Probe -> m () - xInfoProbeOk c1@Contact {contactId = cId1} probe = - withStore' (\db -> matchSentProbe db user (CGMContact c1) probe) >>= \case - Just (CGMContact c2@Contact {contactId = cId2}) - | cId1 /= cId2 -> mergeContacts c1 c2 - | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" - Just (CGMGroupMember g m2@GroupMember {memberContactId}) - | isNothing memberContactId -> connectContactToMember c1 g m2 - | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" - _ -> pure () + xInfoProbeOk :: ContactOrMember -> Probe -> m () + xInfoProbeOk cgm1 probe = do + cgm2 <- withStore' $ \db -> matchSentProbe db user cgm1 probe + case cgm1 of + COMContact c1@Contact {contactId = cId1} -> + case cgm2 of + Just (COMContact c2@Contact {contactId = cId2}) + | cId1 /= cId2 -> void $ mergeContacts c1 c2 + | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + Just (COMGroupMember m2@GroupMember {memberContactId}) -> + case memberContactId of + Nothing -> void $ associateMemberAndContact c1 m2 + Just mCtId + | mCtId /= cId1 -> do + mCt <- withStore $ \db -> getContact db user mCtId + void $ mergeContacts c1 mCt + | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + _ -> pure () + COMGroupMember m1@GroupMember {memberContactId} -> + case cgm2 of + Just (COMContact c2@Contact {contactId = cId2}) -> case memberContactId of + Nothing -> void $ associateMemberAndContact c2 m1 + Just mCtId + | mCtId /= cId2 -> do + mCt <- withStore $ \db -> getContact db user mCtId + void $ mergeContacts c2 mCt + | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + Just (COMGroupMember _) -> messageWarning "xInfoProbeOk ignored: members are not matched with members" + _ -> pure () -- to party accepting call xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> m () @@ -4451,15 +4526,53 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do msgCallStateError eventName Call {callState} = messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) - mergeContacts :: Contact -> Contact -> m () + mergeContacts :: Contact -> Contact -> m (Maybe Contact) mergeContacts c1 c2 = do - withStore' $ \db -> mergeContactRecords db userId c1 c2 - toView $ CRContactsMerged user c1 c2 + let Contact {localDisplayName = cLDN1, profile = LocalProfile {displayName}} = c1 + Contact {localDisplayName = cLDN2} = c2 + case (suffixOrd displayName cLDN1, suffixOrd displayName cLDN2) of + (Just cOrd1, Just cOrd2) + | cOrd1 < cOrd2 -> merge c1 c2 + | cOrd2 < cOrd1 -> merge c2 c1 + | otherwise -> pure Nothing + _ -> pure Nothing + where + merge c1' c2' = do + c2'' <- withStore $ \db -> mergeContactRecords db user c1' c2' + toView $ CRContactsMerged user c1' c2' c2'' + pure $ Just c2'' - connectContactToMember :: Contact -> GroupInfo -> GroupMember -> m () - connectContactToMember c1 g m2 = do - withStore' $ \db -> updateMemberContact db user c1 m2 - toView $ CRMemberContactConnected user c1 g m2 + associateMemberAndContact :: Contact -> GroupMember -> m (Maybe Contact) + associateMemberAndContact c m = do + let Contact {localDisplayName = cLDN, profile = LocalProfile {displayName}} = c + GroupMember {localDisplayName = mLDN} = m + case (suffixOrd displayName cLDN, suffixOrd displayName mLDN) of + (Just cOrd, Just mOrd) + | cOrd < mOrd -> Just <$> associateMemberWithContact c m + | mOrd < cOrd -> Just <$> associateContactWithMember m c + | otherwise -> pure Nothing + _ -> pure Nothing + + suffixOrd :: ContactName -> ContactName -> Maybe Int + suffixOrd displayName localDisplayName + | localDisplayName == displayName = Just 0 + | otherwise = case T.stripPrefix (displayName <> "_") localDisplayName of + Just suffix -> readMaybe $ T.unpack suffix + Nothing -> Nothing + + associateMemberWithContact :: Contact -> GroupMember -> m Contact + associateMemberWithContact c1 m2@GroupMember {groupId} = do + withStore' $ \db -> associateMemberWithContactRecord db user c1 m2 + g <- withStore $ \db -> getGroupInfo db user groupId + toView $ CRContactAndMemberAssociated user c1 g m2 c1 + pure c1 + + associateContactWithMember :: GroupMember -> Contact -> m Contact + associateContactWithMember m1@GroupMember {groupId} c2 = do + c2' <- withStore $ \db -> associateContactWithMemberRecord db user m1 c2 + g <- withStore $ \db -> getGroupInfo db user groupId + toView $ CRContactAndMemberAssociated user c2 g m1 c2' + pure c2' saveConnInfo :: Connection -> ConnInfo -> m Connection saveConnInfo activeConn connInfo = do @@ -5388,6 +5501,7 @@ chatCommandP = ("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath), "/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))), "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))), + "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, "/_db import " *> (APIImportArchive <$> jsonP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 61840b8e84..d859231faa 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -191,7 +191,8 @@ data ChatController = ChatController showLiveItems :: TVar Bool, userXFTPFileConfig :: TVar (Maybe XFTPFileConfig), tempDirectory :: TVar (Maybe FilePath), - logFilePath :: Maybe FilePath + logFilePath :: Maybe FilePath, + contactMergeEnabled :: TVar Bool } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase @@ -230,6 +231,7 @@ data ChatCommand | SetTempFolder FilePath | SetFilesFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) + | SetContactMergeEnabled Bool | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig @@ -490,7 +492,7 @@ data ChatResponse | CRSentConfirmation {user :: User} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} - | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact} + | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} | CRContactDeleted {user :: User, contact :: Contact} | CRContactDeletedByContact {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} @@ -562,7 +564,7 @@ data ChatResponse | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} - | CRMemberContactConnected {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} + | CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} diff --git a/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs b/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs new file mode 100644 index 0000000000..a0f6009af2 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231002_conn_initiated where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231002_conn_initiated :: Query +m20231002_conn_initiated = + [sql| +ALTER TABLE connections ADD COLUMN contact_conn_initiated INTEGER NOT NULL DEFAULT 0; + +UPDATE connections SET conn_req_inv = NULL WHERE conn_status IN ('ready', 'deleted'); + +CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); +CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); +CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); +|] + +down_m20231002_conn_initiated :: Query +down_m20231002_conn_initiated = + [sql| +DROP INDEX idx_sent_probes_created_at; +DROP INDEX idx_sent_probe_hashes_created_at; +DROP INDEX idx_received_probes_created_at; + +ALTER TABLE connections DROP COLUMN contact_conn_initiated; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 65ceb7d19b..e88d83e42d 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -264,6 +264,7 @@ CREATE TABLE connections( peer_chat_min_version INTEGER NOT NULL DEFAULT 1, peer_chat_max_version INTEGER NOT NULL DEFAULT 1, to_subscribe INTEGER DEFAULT 0 NOT NULL, + contact_conn_initiated INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -732,3 +733,6 @@ CREATE INDEX idx_received_probes_user_id ON received_probes(user_id); CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); CREATE INDEX idx_received_probes_probe ON received_probes(probe); CREATE INDEX idx_received_probes_probe_hash ON received_probes(probe_hash); +CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); +CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); +CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 93f3349ca2..383db3c59c 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -58,7 +58,7 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 886c735053..20b81def8f 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -62,6 +62,7 @@ module Simplex.Chat.Store.Direct updateConnectionStatus, updateContactSettings, setConnConnReqInv, + resetContactConnInitiated, ) where @@ -126,11 +127,11 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou db [sql| INSERT INTO connections ( - user_id, agent_conn_id, conn_status, conn_type, + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) + ((userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -151,7 +152,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct @@ -174,13 +175,14 @@ createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation - createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile + let contactConnInitiated = pccConnStatus == ConnNew DB.execute db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?) + (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (userId, acId, cReq, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) + (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -508,7 +510,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct @@ -672,7 +674,7 @@ getContact_ db user@User {userId} contactId deleted = ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct @@ -722,7 +724,7 @@ getContactConnections db userId Contact {contactId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id @@ -739,7 +741,7 @@ getConnectionById db User {userId} connId = ExceptT $ do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? @@ -773,7 +775,11 @@ getConnectionsContacts db agentConnIds = do updateConnectionStatus :: DB.Connection -> Connection -> ConnStatus -> IO () updateConnectionStatus db Connection {connId} connStatus = do currentTs <- getCurrentTime - DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) + if connStatus == ConnReady + then + DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId) + else + DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) updateContactSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateContactSettings db User {userId} contactId ChatSettings {enableNtfs, sendRcpts, favorite} = @@ -790,3 +796,16 @@ setConnConnReqInv db User {userId} connId connReq = do WHERE user_id = ? AND connection_id = ? |] (connReq, updatedAt, userId, connId) + +resetContactConnInitiated :: DB.Connection -> User -> Connection -> IO () +resetContactConnInitiated db User {userId} Connection {connId} = do + updatedAt <- getCurrentTime + DB.execute + db + [sql| + UPDATE connections + SET contact_conn_initiated = 0, updated_at = ? + WHERE user_id = ? AND connection_id = ? + |] + (updatedAt, userId, connId) + diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index e72ca8e8ca..f6a4233c1f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -77,6 +77,7 @@ module Simplex.Chat.Store.Groups getViaGroupMember, getViaGroupContact, getMatchingContacts, + getMatchingMembers, getMatchingMemberContacts, createSentProbe, createSentProbeHash, @@ -84,7 +85,9 @@ module Simplex.Chat.Store.Groups matchReceivedProbeHash, matchSentProbe, mergeContactRecords, - updateMemberContact, + associateMemberWithContactRecord, + associateContactWithMemberRecord, + deleteOldProbes, updateGroupSettings, getXGrpMemIntroContDirect, getXGrpMemIntroContGroup, @@ -105,7 +108,7 @@ import Crypto.Random (ChaChaDRG) import Data.Either (rights) import Data.Int (Int64) import Data.List (sortOn) -import Data.Maybe (fromMaybe, isNothing) +import Data.Maybe (fromMaybe, isNothing, catMaybes, isJust) import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) @@ -169,7 +172,7 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -251,7 +254,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -544,7 +547,7 @@ groupMemberQuery = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -703,7 +706,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct @@ -1013,7 +1016,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id @@ -1047,7 +1050,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id @@ -1176,13 +1179,32 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro AND p.display_name = ? AND p.full_name = ? |] +getMatchingMembers :: DB.Connection -> User -> Contact -> IO [GroupMember] +getMatchingMembers db user@User {userId} Contact {profile = LocalProfile {displayName, fullName, image}} = do + memberIds <- + map fromOnly <$> case image of + Just img -> DB.query db (q <> " AND p.image = ?") (userId, GCUserMember, displayName, fullName, img) + Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, GCUserMember, displayName, fullName) + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + where + -- only match with members without associated contact + q = + [sql| + SELECT m.group_member_id + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE m.user_id = ? AND m.contact_id IS NULL + AND m.member_category != ? + AND p.display_name = ? AND p.full_name = ? + |] + getMatchingMemberContacts :: DB.Connection -> User -> GroupMember -> IO [Contact] getMatchingMemberContacts _ _ GroupMember {memberContactId = Just _} = pure [] getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of - Just img -> DB.query db (q <> " AND p.image = ?") (userId, displayName, fullName, img) - Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, displayName, fullName) + Just img -> DB.query db (q <> " AND p.image = ?") (userId, CSActive, displayName, fullName, img) + Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, CSActive, displayName, fullName) rights <$> mapM (runExceptT . getContact db user) contactIds where q = @@ -1191,55 +1213,54 @@ getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = Loc FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? - AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND p.display_name = ? AND p.full_name = ? |] -createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> ContactOrGroupMember -> ExceptT StoreError IO (Probe, Int64) +createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> ContactOrMember -> ExceptT StoreError IO (Probe, Int64) createSentProbe db gVar userId to = createWithRandomBytes 32 gVar $ \probe -> do currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds to + let (ctId, gmId) = contactOrMemberIds to DB.execute db "INSERT INTO sent_probes (contact_id, group_member_id, probe, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, probe, userId, currentTs, currentTs) - (Probe probe,) <$> insertedRowId db + (Probe probe,) <$> insertedRowId db -createSentProbeHash :: DB.Connection -> UserId -> Int64 -> ContactOrGroupMember -> IO () +createSentProbeHash :: DB.Connection -> UserId -> Int64 -> ContactOrMember -> IO () createSentProbeHash db userId probeId to = do currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds to + let (ctId, gmId) = contactOrMemberIds to DB.execute db "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, group_member_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (probeId, ctId, gmId, userId, currentTs, currentTs) -matchReceivedProbe :: DB.Connection -> User -> ContactOrGroupMember -> Probe -> IO (Maybe ContactOrGroupMember) +matchReceivedProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] matchReceivedProbe db user@User {userId} from (Probe probe) = do let probeHash = C.sha256Hash probe cgmIds <- - maybeFirstRow id $ - DB.query - db - [sql| - SELECT r.contact_id, g.group_id, r.group_member_id - FROM received_probes r - LEFT JOIN contacts c ON r.contact_id = c.contact_id AND c.deleted = 0 - LEFT JOIN group_members m ON r.group_member_id = m.group_member_id - LEFT JOIN groups g ON g.group_id = m.group_id - WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL - |] - (userId, probeHash) + DB.query + db + [sql| + SELECT r.contact_id, g.group_id, r.group_member_id + FROM received_probes r + LEFT JOIN contacts c ON r.contact_id = c.contact_id AND c.deleted = 0 + LEFT JOIN group_members m ON r.group_member_id = m.group_member_id + LEFT JOIN groups g ON g.group_id = m.group_id + WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL + |] + (userId, probeHash) currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds from + let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (ctId, gmId, probe, probeHash, userId, currentTs, currentTs) - pure cgmIds $>>= getContactOrGroupMember_ db user + catMaybes <$> mapM (getContactOrMember_ db user) cgmIds -matchReceivedProbeHash :: DB.Connection -> User -> ContactOrGroupMember -> ProbeHash -> IO (Maybe (ContactOrGroupMember, Probe)) +matchReceivedProbeHash :: DB.Connection -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do probeIds <- maybeFirstRow id $ @@ -1255,18 +1276,18 @@ matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do |] (userId, probeHash) currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds from + let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, probeHash, userId, currentTs, currentTs) - pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrGroupMember_ db user cgmIds + pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db user cgmIds -matchSentProbe :: DB.Connection -> User -> ContactOrGroupMember -> Probe -> IO (Maybe ContactOrGroupMember) -matchSentProbe db user@User {userId} _from (Probe probe) = - cgmIds $>>= getContactOrGroupMember_ db user +matchSentProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) +matchSentProbe db user@User {userId} _from (Probe probe) = + cgmIds $>>= getContactOrMember_ db user where - (ctId, gmId) = contactOrGroupMemberIds _from + (ctId, gmId) = contactOrMemberIds _from cgmIds = maybeFirstRow id $ DB.query @@ -1283,80 +1304,103 @@ matchSentProbe db user@User {userId} _from (Probe probe) = |] (userId, probe, ctId, gmId) -getContactOrGroupMember_ :: DB.Connection -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrGroupMember) -getContactOrGroupMember_ db user ids = +getContactOrMember_ :: DB.Connection -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) +getContactOrMember_ db user ids = fmap eitherToMaybe . runExceptT $ case ids of - (Just ctId, _, _) -> CGMContact <$> getContact db user ctId - (_, Just gId, Just gmId) -> CGMGroupMember <$> getGroupInfo db user gId <*> getGroupMember db user gId gmId + (Just ctId, _, _) -> COMContact <$> getContact db user ctId + (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db user gId gmId _ -> throwError $ SEInternalError "" -mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO () -mergeContactRecords db userId ct1 ct2 = do - let (toCt, fromCt) = toFromContacts ct1 ct2 - Contact {contactId = toContactId} = toCt - Contact {contactId = fromContactId, localDisplayName} = fromCt - currentTs <- getCurrentTime - -- TODO next query fixes incorrect unused contacts deletion; consider more thorough fix - when (contactDirect toCt && not (contactUsed toCt)) $ +-- connection being verified and connection level 0 have priority over requested merge direction; +-- if requested merge direction is overruled, keepLDN is kept +mergeContactRecords :: DB.Connection -> User -> Contact -> Contact -> ExceptT StoreError IO Contact +mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN} from = do + let (toCt, fromCt) = checkToFromContacts + Contact {contactId = toContactId, localDisplayName = toLDN} = toCt + Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt + liftIO $ do + currentTs <- getCurrentTime + -- next query fixes incorrect unused contacts deletion + when (contactDirect toCt && not (contactUsed toCt)) $ + DB.execute + db + "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" + (currentTs, userId, toContactId) DB.execute db - "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" - (currentTs, userId, toContactId) - DB.execute - db - "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.executeNamed - db - [sql| - UPDATE group_members - SET contact_id = :to_contact_id, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), - updated_at = :updated_at - WHERE contact_id = :from_contact_id - AND user_id = :user_id - |] - [ ":to_contact_id" := toContactId, - ":from_contact_id" := fromContactId, - ":user_id" := userId, - ":updated_at" := currentTs - ] - deleteContactProfile_ db userId fromContactId - DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) - deleteUnusedDisplayName_ db userId localDisplayName + "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE sent_probes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE sent_probe_hashes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE received_probes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.executeNamed + db + [sql| + UPDATE group_members + SET contact_id = :to_contact_id, + local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), + contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), + updated_at = :updated_at + WHERE contact_id = :from_contact_id + AND user_id = :user_id + |] + [ ":to_contact_id" := toContactId, + ":from_contact_id" := fromContactId, + ":user_id" := userId, + ":updated_at" := currentTs + ] + deleteContactProfile_ db userId fromContactId + DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) + deleteUnusedDisplayName_ db userId fromLDN + when (keepLDN /= toLDN && keepLDN == fromLDN) $ + DB.execute + db + [sql| + UPDATE display_names + SET local_display_name = ?, updated_at = ? + WHERE user_id = ? AND local_display_name = ? + |] + (keepLDN, currentTs, userId, toLDN) + getContact db user toContactId where - toFromContacts :: Contact -> Contact -> (Contact, Contact) - toFromContacts c1 c2 - | d1 && not d2 = (c1, c2) - | d2 && not d1 = (c2, c1) - | ctCreatedAt c1 <= ctCreatedAt c2 = (c1, c2) - | otherwise = (c2, c1) + checkToFromContacts :: (Contact, Contact) + checkToFromContacts + | vrfFrom && not vrfTo = (from, to) + | dirFrom && not vrfTo && not dirTo = (from, to) + | otherwise = (to, from) where - d1 = directOrUsed c1 - d2 = directOrUsed c2 - ctCreatedAt Contact {createdAt} = createdAt + vrfTo = isJust $ contactSecurityCode to + vrfFrom = isJust $ contactSecurityCode from + dirTo = let Contact {activeConn = Connection {connLevel = clTo}} = to in clTo == 0 + dirFrom = let Contact {activeConn = Connection {connLevel = clFrom}} = from in clFrom == 0 -updateMemberContact :: DB.Connection -> User -> Contact -> GroupMember -> IO () -updateMemberContact +associateMemberWithContactRecord :: DB.Connection -> User -> Contact -> GroupMember -> IO () +associateMemberWithContactRecord db User {userId} Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} = do - -- TODO possibly, we should update profiles and local_display_names of all members linked to the same remote user, - -- once we decide on how we identify it, either based on shared contact_profile_id or on local_display_name currentTs <- getCurrentTime DB.execute db @@ -1369,6 +1413,34 @@ updateMemberContact when (memProfileId /= profileId) $ deleteUnusedProfile_ db userId memProfileId when (memLDN /= localDisplayName) $ deleteUnusedDisplayName_ db userId memLDN +associateContactWithMemberRecord :: DB.Connection -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact +associateContactWithMemberRecord + db + user@User {userId} + GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} + Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} = do + liftIO $ do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET contact_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + |] + (contactId, currentTs, userId, groupId, groupMemberId) + DB.execute + db + [sql| + UPDATE contacts + SET local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + |] + (memLDN, memProfileId, currentTs, userId, contactId) + when (profileId /= memProfileId) $ deleteUnusedProfile_ db userId profileId + when (localDisplayName /= memLDN) $ deleteUnusedDisplayName_ db userId localDisplayName + getContact db user contactId + deleteUnusedDisplayName_ :: DB.Connection -> UserId -> ContactName -> IO () deleteUnusedDisplayName_ db userId localDisplayName = DB.executeNamed @@ -1407,6 +1479,12 @@ deleteUnusedDisplayName_ db userId localDisplayName = |] [":user_id" := userId, ":local_display_name" := localDisplayName] +deleteOldProbes :: DB.Connection -> UTCTime -> IO () +deleteOldProbes db createdAtCutoff = do + DB.execute db "DELETE FROM sent_probes WHERE created_at <= ?" (Only createdAtCutoff) + DB.execute db "DELETE FROM sent_probe_hashes WHERE created_at <= ?" (Only createdAtCutoff) + DB.execute db "DELETE FROM received_probes WHERE created_at <= ?" (Only createdAtCutoff) + updateGroupSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateGroupSettings db User {userId} groupId ChatSettings {enableNtfs, sendRcpts, favorite} = DB.execute db "UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, sendRcpts, favorite, userId, groupId) @@ -1511,15 +1589,15 @@ createMemberContact db [sql| INSERT INTO connections ( - user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, + user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_conn_initiated, contact_id, custom_user_profile_id, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, contactId, customUserProfileId) + ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, True, contactId, customUserProfileId) :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db - let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = True, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} @@ -1622,9 +1700,9 @@ createMemberContactConn_ peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, connLevel, ConnNew, ConnContact, contactId, customUserProfileId) + ( (userId, acId, connLevel, ConnJoined, ConnContact, contactId, customUserProfileId) :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db setCommandConnId db user cmdId connId - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 458944b6e5..b996f7626f 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -481,7 +481,7 @@ getDirectChatPreviews_ db user@User {userId} = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version, -- ChatStats diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 2f5bfec9ea..3ef68874b0 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -82,6 +82,7 @@ import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes import Simplex.Chat.Migrations.M20230926_contact_status +import Simplex.Chat.Migrations.M20231002_conn_initiated import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -163,7 +164,8 @@ schemaMigrations = ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), - ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status) + ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), + ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index e521cb43cf..a577796810 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -320,7 +320,7 @@ getUserAddressConnections db User {userId} = do db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -335,7 +335,7 @@ getUserContactLinks db User {userId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 4dc4f6e82d..3ff765b75d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -138,16 +138,16 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = let entityId = entityId_ connType connectionCode = SecurityCode <$> code_ <*> verifiedAt_ peerChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, authErrCounter, createdAt} + in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias, entityId, connectionCode, authErrCounter, createdAt} where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -157,8 +157,8 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> IO Connection @@ -180,7 +180,7 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange :. (minV, maxV, subMode == SMOnlyCreate) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange = JVersionRange peerChatVRange, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange = JVersionRange peerChatVRange, connType, contactConnInitiated = False, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} where ent ct = if connType == ct then entityId else Nothing diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 43265671b6..529d2bf019 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -244,18 +244,18 @@ data ContactRef = ContactRef instance ToJSON ContactRef where toEncoding = J.genericToEncoding J.defaultOptions -data ContactOrGroupMember = CGMContact Contact | CGMGroupMember GroupInfo GroupMember +data ContactOrMember = COMContact Contact | COMGroupMember GroupMember deriving (Show) -contactOrGroupMemberIds :: ContactOrGroupMember -> (Maybe ContactId, Maybe GroupMemberId) -contactOrGroupMemberIds = \case - CGMContact Contact {contactId} -> (Just contactId, Nothing) - CGMGroupMember _ GroupMember {groupMemberId} -> (Nothing, Just groupMemberId) +contactOrMemberIds :: ContactOrMember -> (Maybe ContactId, Maybe GroupMemberId) +contactOrMemberIds = \case + COMContact Contact {contactId} -> (Just contactId, Nothing) + COMGroupMember GroupMember {groupMemberId} -> (Nothing, Just groupMemberId) -contactOrGroupMemberIncognito :: ContactOrGroupMember -> IncognitoEnabled -contactOrGroupMemberIncognito = \case - CGMContact ct -> contactConnIncognito ct - CGMGroupMember _ m -> memberIncognito m +contactOrMemberIncognito :: ContactOrMember -> IncognitoEnabled +contactOrMemberIncognito = \case + COMContact ct -> contactConnIncognito ct + COMGroupMember m -> memberIncognito m data UserContact = UserContact { userContactLinkId :: Int64, @@ -1235,6 +1235,7 @@ data Connection = Connection customUserProfileId :: Maybe Int64, connType :: ConnType, connStatus :: ConnStatus, + contactConnInitiated :: Bool, localAlias :: Text, entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID connectionCode :: Maybe SecurityCode, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5eb9df3ad2..b981929efc 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -175,7 +175,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' - CRContactsMerged u intoCt mergedCt -> ttyUser u $ viewContactsMerged intoCt mergedCt + CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci @@ -236,7 +236,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] - CRMemberContactConnected u ct g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " is merged into " <> ttyContact' ct] + CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g @@ -891,12 +891,18 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " -viewContactsMerged :: Contact -> Contact -> [StyledString] -viewContactsMerged c1 c2 = +viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] +viewContactsMerged c1 c2 ct' = [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, - "use " <> ttyToContact' c1 <> highlight' "" <> " to send messages" + "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" ] +viewContactAndMemberAssociated :: Contact -> GroupInfo -> GroupMember -> Contact -> [StyledString] +viewContactAndMemberAssociated ct g m ct' = + [ "contact and member are merged: " <> ttyContact' ct <> ", " <> ttyGroup' g <> " " <> ttyMember m, + "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" + ] + viewUserProfile :: Profile -> [StyledString] viewUserProfile Profile {displayName, fullName} = [ "user profile: " <> ttyFullName displayName fullName, diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 5c4d96cc94..a8f9fbf9c6 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -29,7 +29,8 @@ import Test.Hspec chatDirectTests :: SpecWith FilePath chatDirectTests = do describe "direct messages" $ do - describe "add contact and send/receive message" testAddContact + describe "add contact and send/receive messages" testAddContact + it "clear chat with contact" testContactClear it "deleting contact deletes profile" testDeleteContactDeletesProfile it "unused contact is deleted silently" testDeleteUnusedContactSilent it "direct message quoted replies" testDirectMessageQuotedReply @@ -40,6 +41,9 @@ chatDirectTests = do it "direct timed message" testDirectTimedMessage it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact it "should send multiline message" testMultilineMessage + describe "contact merge" $ do + it "merge duplicate contacts" testContactMerge + it "new contact should merge with multiple existing contacts" testMergeContactMultipleContacts describe "SMP servers" $ do it "get and set SMP servers" testGetSetSMPServers it "test SMP server connection" testTestSMPServerConnection @@ -140,35 +144,6 @@ testAddContact = versionTestMatrix2 runTestAddContact bob #> "@alice how are you?" alice <# "bob> how are you?" chatsManyMessages alice bob - -- test adding the same contact one more time - local name will be different - alice ##> "/c" - inv' <- getInvitation alice - bob ##> ("/c " <> inv') - bob <## "confirmation sent!" - concurrently_ - (bob <## "alice_1 (Alice): contact is connected") - (alice <## "bob_1 (Bob): contact is connected") - alice #> "@bob_1 hello" - bob <# "alice_1> hello" - bob #> "@alice_1 hi" - alice <# "bob_1> hi" - alice @@@ [("@bob_1", "hi"), ("@bob", "how are you?")] - bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")] - -- test deleting contact - alice ##> "/d bob_1" - alice <## "bob_1: contact is deleted" - bob <## "alice_1 (Alice) deleted contact with you" - alice ##> "@bob_1 hey" - alice <## "no contact bob_1" - alice @@@ [("@bob", "how are you?")] - alice `hasContactProfiles` ["alice", "bob"] - bob @@@ [("@alice_1", "contact deleted"), ("@alice", "how are you?")] - bob `hasContactProfiles` ["alice", "alice", "bob"] - -- test clearing chat - alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") - alice #$> ("/_get chat @2 count=100", chat, []) - bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") - bob #$> ("/_get chat @2 count=100", chat, []) chatsEmpty alice bob = do alice @@@ [("@bob", lastChatFeature)] alice #$> ("/_get chat @2 count=100", chat, chatFeatures) @@ -195,6 +170,101 @@ testAddContact = versionTestMatrix2 runTestAddContact alice #$> ("/_read chat @2", id, "ok") bob #$> ("/_read chat @2", id, "ok") +testContactMerge :: HasCallStack => FilePath -> IO () +testContactMerge = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob + + alice ##> "/c" + inv' <- getInvitation alice + bob ##> ("/c " <> inv') + bob <## "confirmation sent!" + concurrentlyN_ + [ alice + <### [ "bob_1 (Bob): contact is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + bob + <### [ "alice_1 (Alice): contact is connected", + "contact alice_1 is merged into alice", + "use @alice to send messages" + ] + ] + alice <##> bob + alice @@@ [("@bob", "hey")] + alice `hasContactProfiles` ["alice", "bob"] + bob @@@ [("@alice", "hey")] + bob `hasContactProfiles` ["bob", "alice"] + +testMergeContactMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeContactMultipleContacts = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + bob ##> "/contact_merge off" + bob <## "ok" + + connectUsers alice bob + + alice ##> "/c" + inv' <- getInvitation alice + bob ##> ("/c " <> inv') + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob_1 (Bob): contact is connected") + (bob <## "alice_1 (Alice): contact is connected") + + alice `hasContactProfiles` ["alice", "bob", "bob"] + bob `hasContactProfiles` ["bob", "alice", "alice"] + + threadDelay 500000 + + bob ##> "/contact_merge on" + bob <## "ok" + + alice ##> "/c" + inv'' <- getInvitation alice + bob ##> ("/c " <> inv'') + bob <## "confirmation sent!" + concurrentlyN_ + [ alice + <### [ "bob_2 (Bob): contact is connected", + StartsWith "contact bob_2 is merged into bob", + StartsWith "use @bob", + StartsWith "contact bob_1 is merged into bob", + StartsWith "use @bob" + ], + bob + <### [ "alice_2 (Alice): contact is connected", + StartsWith "contact alice_2 is merged into alice", + StartsWith "use @alice", + StartsWith "contact alice_1 is merged into alice", + StartsWith "use @alice" + ] + ] + alice <##> bob + + alice ##> "/contacts" + alice <## "bob (Bob)" + bob ##> "/contacts" + bob <## "alice (Alice)" + alice `hasContactProfiles` ["alice", "bob"] + bob `hasContactProfiles` ["bob", "alice"] + +testContactClear :: HasCallStack => FilePath -> IO () +testContactClear = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob + threadDelay 500000 + alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") + alice #$> ("/_get chat @2 count=100", chat, []) + bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") + bob #$> ("/_get chat @2 count=100", chat, []) + testDeleteContactDeletesProfile :: HasCallStack => FilePath -> IO () testDeleteContactDeletesProfile = testChat2 aliceProfile bobProfile $ @@ -2104,7 +2174,7 @@ testMsgDecryptError tmp = withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" alice #> "@bob hello again" - bob <# "alice> skipped message ID 9..11" + bob <# "alice> skipped message ID 10..12" bob <# "alice> hello again" bob #> "@alice received!" alice <# "bob> received!" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 4280810ca6..083d2f85be 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -73,7 +73,14 @@ chatGroupTests = do testNoDirect _1 _0 False testNoDirect _1 _1 False it "members have different local display names in different groups" testNoDirectDifferentLDNs - it "member should connect to contact when profile match" testConnectMemberToContact + describe "merge members and contacts" $ do + it "new member should merge with existing contact" testMergeMemberExistingContact + it "new member should merge with multiple existing contacts" testMergeMemberMultipleContacts + it "new contact should merge with existing member" testMergeContactExistingMember + it "new contact should merge with existing member with associated contact" testMergeContactExistingMemberWithContact + it "new contact should merge with multiple existing members" testMergeContactMultipleMembers + it "new contact should merge with both existing members and contacts" testMergeContactExistingMembersAndContacts + it "new member contact is merged with existing contact" testMergeMemberContact describe "create member contact" $ do it "create contact with group member with invitation message" testMemberContactMessage it "create contact with group member without invitation message" testMemberContactNoMessage @@ -2734,8 +2741,8 @@ testNoDirectDifferentLDNs = bob <# ("#" <> gName <> " " <> cathLDN <> "> hey") ] -testConnectMemberToContact :: HasCallStack => FilePath -> IO () -testConnectMemberToContact = +testMergeMemberExistingContact :: HasCallStack => FilePath -> IO () +testMergeMemberExistingContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob @@ -2750,13 +2757,15 @@ testConnectMemberToContact = [ do alice <## "#team: you joined the group" alice <## "#team: member cath_1 (Catherine) is connected" - alice <## "member #team cath_1 is merged into cath", + alice <## "contact and member are merged: cath, #team cath_1" + alice <## "use @cath to send messages", do bob <## "#team: alice joined the group", do cath <## "#team: bob added alice_1 (Alice) to the group (connecting...)" cath <## "#team: new member alice_1 is connected" - cath <## "member #team alice_1 is merged into alice" + cath <## "contact and member are merged: alice, #team alice_1" + cath <## "use @alice to send messages" ] alice <##> cath alice #> "#team hello" @@ -2779,6 +2788,334 @@ testConnectMemberToContact = alice `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] +testMergeMemberMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeMemberMultipleContacts = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + alice ##> "/contact_merge off" + alice <## "ok" + + connectUsers alice bob + connectUsers alice cath + + alice ##> "/c" + inv' <- getInvitation alice + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrently_ + (alice <## "cath_1 (Catherine): contact is connected") + (cath <## "alice_1 (Alice): contact is connected") + + alice `hasContactProfiles` ["alice", "bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "alice", "alice"] + + alice ##> "/contact_merge on" + alice <## "ok" + + createGroup2 "team" bob cath + bob ##> "/a #team alice" + bob <## "invitation to join the group #team sent to alice" + alice <## "#team: bob invites you to join the group as member" + alice <## "use /j team to accept" + alice ##> "/j team" + concurrentlyN_ + [ alice + <### [ "#team: you joined the group", + "#team: member cath_2 (Catherine) is connected", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath", + StartsWith "contact cath_", + StartsWith "use @cath" + ], + bob <## "#team: alice joined the group", + cath + <### [ "#team: bob added alice_2 (Alice) to the group (connecting...)", + "#team: new member alice_2 is connected", + StartsWith "contact and member are merged: alice", + StartsWith "use @alice", + StartsWith "contact alice_", + StartsWith "use @alice" + ] + ] + alice <##> cath + alice #> "#team hello" + bob <# "#team alice> hello" + cath <# "#team alice> hello" + cath #> "#team hello too" + bob <# "#team cath> hello too" + alice <# "#team cath> hello too" + + alice ##> "/contacts" + alice + <### [ "bob (Bob)", + "cath (Catherine)" + ] + cath ##> "/contacts" + cath + <### [ "alice (Alice)", + "bob (Bob)" + ] + alice `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeContactExistingMember :: HasCallStack => FilePath -> IO () +testMergeContactExistingMember = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_1 (Catherine): contact is connected", + "contact and member are merged: cath_1, #team cath", + "use @cath to send messages" + ], + cath + <### [ "bob_1 (Bob): contact is connected", + "contact and member are merged: bob_1, #team bob", + "use @bob to send messages" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeContactExistingMemberWithContact :: HasCallStack => FilePath -> IO () +testMergeContactExistingMemberWithContact = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + -- create contact, delete only for bob so he would send probe hash to member + bob ##> "/_create member contact #1 3" + bob <## "contact for member #team cath is created" + + bob ##> "/_invite member contact @3 text hi" + bob + <### [ "sent invitation to connect directly to member #team cath", + WithTime "@cath hi" + ] + cath + <### [ "#team bob is creating direct contact bob with you", + WithTime "bob> hi" + ] + concurrently_ + (bob <## "cath (Catherine): contact is connected") + (cath <## "bob (Bob): contact is connected") + bob <##> cath + + bob ##> "/_delete @3 notify=off" + bob <## "cath: contact is deleted" + + bob ##> "/contacts" + bob <### ["alice (Alice)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + + -- contact connects, member is merged + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_1 (Catherine): contact is connected", + "contact and member are merged: cath_1, #team cath", + "use @cath to send messages" + ], + cath + <### [ "bob_1 (Bob): contact is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeContactMultipleMembers :: HasCallStack => FilePath -> IO () +testMergeContactMultipleMembers = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + create2Groups3 "team" "club" alice bob cath + + bob `hasContactProfiles` ["alice", "bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob", "bob"] + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_2 (Catherine): contact is connected", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath" + ], + cath + <### [ "bob_2 (Bob): contact is connected", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeContactExistingMembersAndContacts :: HasCallStack => FilePath -> IO () +testMergeContactExistingMembersAndContacts = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + bob ##> "/contact_merge off" + bob <## "ok" + + create2Groups3 "team" "club" alice bob cath + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrently_ + (bob <## "cath_2 (Catherine): contact is connected") + (cath <## "bob_2 (Bob): contact is connected") + + bob `hasContactProfiles` ["alice", "bob", "cath", "cath", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob", "bob", "bob"] + + bob ##> "/contact_merge on" + bob <## "ok" + + bob ##> "/c" + inv'' <- getInvitation bob + cath ##> ("/c " <> inv'') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_3 (Catherine): contact is connected", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath", + StartsWith "contact cath_3 is merged into cath", + StartsWith "use @cath" + ], + cath + <### [ "bob_3 (Bob): contact is connected", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob", + StartsWith "contact bob_3 is merged into bob", + StartsWith "use @bob" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeMemberContact :: HasCallStack => FilePath -> IO () +testMergeMemberContact = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + bob ##> "/contact_merge off" + bob <## "ok" + + createGroup3 "team" alice bob cath + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrently_ + (bob <## "cath_1 (Catherine): contact is connected") + (cath <## "bob_1 (Bob): contact is connected") + + bob `hasContactProfiles` ["alice", "bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob", "bob"] + + bob ##> "/contact_merge on" + bob <## "ok" + + -- bob and cath connect + bob ##> "/_create member contact #1 3" + bob <## "contact for member #team cath is created" + + bob ##> "/_invite member contact @4 text hi" + bob + <### [ "sent invitation to connect directly to member #team cath", + WithTime "@cath hi" + ] + cath + <### [ "#team bob is creating direct contact bob with you", + WithTime "bob> hi" + ] + concurrentlyN_ + [ bob + <### [ "cath (Catherine): contact is connected", + "contact cath_1 is merged into cath", + -- StartsWith "use @cath" + "use @cath to send messages" + ], + cath + <### [ "bob (Bob): contact is connected", + "contact bob_1 is merged into bob", + -- StartsWith "use @bob" + "use @bob to send messages" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + + -- group messages work + alice #> "#team hello" + concurrently_ + (bob <# "#team alice> hello") + (cath <# "#team alice> hello") + bob #> "#team hi there" + concurrently_ + (alice <# "#team bob> hi there") + (cath <# "#team bob> hi there") + cath #> "#team hey team" + concurrently_ + (alice <# "#team cath> hey team") + (bob <# "#team cath> hey team") + testMemberContactMessage :: HasCallStack => FilePath -> IO () testMemberContactMessage = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index da6cbd156f..d7cf682560 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -20,6 +20,7 @@ chatProfileTests = do it "use multiword profile names" testMultiWordProfileNames describe "user contact link" $ do it "create and connect via contact link" testUserContactLink + it "merge existing contact when connecting via contact link" testUserContactLinkMerge it "add contact link to profile" testProfileLink it "auto accept contact requests" testUserContactLinkAutoAccept it "deduplicate contact requests" testDeduplicateContactRequests @@ -218,6 +219,39 @@ testUserContactLink = alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath +testUserContactLinkMerge :: HasCallStack => FilePath -> IO () +testUserContactLinkMerge = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob + + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob) wants to connect to you!" + alice <## "to accept: /ac bob_1" + alice <## "to reject: /rc bob_1 (the sender will NOT be notified)" + alice @@@ [("@bob", "hey"), ("<@bob_1", "")] + alice ##> "/ac bob_1" + alice <## "bob_1 (Bob): accepting contact request..." + concurrentlyN_ + [ alice + <### [ "bob_1 (Bob): contact is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + bob + <### [ "alice_1 (Alice): contact is connected", + "contact alice_1 is merged into alice", + "use @alice to send messages" + ] + ] + threadDelay 100000 + alice @@@ [("@bob", lastChatFeature)] + alice <##> bob + testProfileLink :: HasCallStack => FilePath -> IO () testProfileLink = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 6831cf3190..d98431e815 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -277,7 +277,7 @@ cc <##.. ls = do unless prefix $ print ("expected to start from one of: " <> show ls, ", got: " <> l) prefix `shouldBe` True -data ConsoleResponse = ConsoleString String | WithTime String | EndsWith String +data ConsoleResponse = ConsoleString String | WithTime String | EndsWith String | StartsWith String deriving (Show) instance IsString ConsoleResponse where fromString = ConsoleString @@ -287,7 +287,7 @@ getInAnyOrder :: HasCallStack => (String -> String) -> TestCC -> [ConsoleRespons getInAnyOrder _ _ [] = pure () getInAnyOrder f cc ls = do line <- f <$> getTermLine cc - let rest = filter (not . expected line) ls + let rest = filterFirst (expected line) ls if length rest < length ls then getInAnyOrder f cc rest else error $ "unexpected output: " <> line @@ -297,6 +297,12 @@ getInAnyOrder f cc ls = do ConsoleString s -> l == s WithTime s -> dropTime_ l == Just s EndsWith s -> s `isSuffixOf` l + StartsWith s -> s `isPrefixOf` l + filterFirst :: (a -> Bool) -> [a] -> [a] + filterFirst _ [] = [] + filterFirst p (x:xs) + | p x = xs + | otherwise = x : filterFirst p xs (<###) :: HasCallStack => TestCC -> [ConsoleResponse] -> Expectation (<###) = getInAnyOrder id @@ -456,8 +462,11 @@ showName (TestCC ChatController {currentUser} _ _ _ _ _) = do pure . T.unpack $ localDisplayName <> optionalFullName localDisplayName fullName createGroup2 :: HasCallStack => String -> TestCC -> TestCC -> IO () -createGroup2 gName cc1 cc2 = do - connectUsers cc1 cc2 +createGroup2 gName cc1 cc2 = createGroup2' gName cc1 cc2 True + +createGroup2' :: HasCallStack => String -> TestCC -> TestCC -> Bool -> IO () +createGroup2' gName cc1 cc2 doConnectUsers = do + when doConnectUsers $ connectUsers cc1 cc2 name2 <- userName cc2 cc1 ##> ("/g " <> gName) cc1 <## ("group #" <> gName <> " is created") @@ -488,6 +497,24 @@ createGroup3 gName cc1 cc2 cc3 = do cc2 <## ("#" <> gName <> ": new member " <> name3 <> " is connected") ] +create2Groups3 :: HasCallStack => String -> String -> TestCC -> TestCC -> TestCC -> IO () +create2Groups3 gName1 gName2 cc1 cc2 cc3 = do + createGroup3 gName1 cc1 cc2 cc3 + createGroup2' gName2 cc1 cc2 False + name1 <- userName cc1 + name3 <- userName cc3 + addMember gName2 cc1 cc3 GRAdmin + cc3 ##> ("/j " <> gName2) + concurrentlyN_ + [ cc1 <## ("#" <> gName2 <> ": " <> name3 <> " joined the group"), + do + cc3 <## ("#" <> gName2 <> ": you joined the group") + cc3 <##. ("#" <> gName2 <> ": member "), -- "#gName2: member sName2 is connected" + do + cc2 <##. ("#" <> gName2 <> ": " <> name1 <> " added ") -- "#gName2: name1 added sName3 to the group (connecting...)" + cc2 <##. ("#" <> gName2 <> ": new member ") -- "#gName2: new member name3 is connected" + ] + addMember :: HasCallStack => String -> TestCC -> TestCC -> GroupMemberRole -> IO () addMember gName = fullAddMember gName "" From fc9db9c38182e9b9fb2edae472be6d18ddfbc9e2 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 5 Oct 2023 21:49:20 +0300 Subject: [PATCH 040/294] core: add FromJSON instance to ChatResponse (#3129) * Start adding FromJSON instances to ChatResponse * progress * FromJSON instance for ChatResponse compiles * restore removed encodings * remove comment * diff * update simplexmq, use TH for JSON --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 8 +- src/Simplex/Chat/Call.hs | 20 +- src/Simplex/Chat/Controller.hs | 101 ++++++---- src/Simplex/Chat/Markdown.hs | 25 ++- src/Simplex/Chat/Messages.hs | 243 +++++++++++++++++++++---- src/Simplex/Chat/Messages/CIContent.hs | 110 +++++------ src/Simplex/Chat/Protocol.hs | 3 + src/Simplex/Chat/Remote.hs | 2 +- src/Simplex/Chat/Remote/Types.hs | 8 +- src/Simplex/Chat/Store/Profiles.hs | 7 +- src/Simplex/Chat/Store/Shared.hs | 5 +- src/Simplex/Chat/Types.hs | 134 +++++++++----- src/Simplex/Chat/Types/Preferences.hs | 7 +- src/Simplex/Chat/Types/Util.hs | 3 + src/Simplex/Chat/View.hs | 10 +- stack.yaml | 2 +- 18 files changed, 483 insertions(+), 209 deletions(-) diff --git a/cabal.project b/cabal.project index f5bb879762..03b3bc8100 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 753a6c7542c3764fda9ce3f4c4cdc9f2329816d3 + tag: 96a38505d63ec9a12096991e7725b250e397af72 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index b6ca36e313..748da0363c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ec1b72cb8013a65a5d9783104a47ae44f5730089" = "1lz5rvgxp242zg95r9zd9j50y45314cf8nfpjg1qsa55nrk2w19b"; + "https://github.com/simplex-chat/simplexmq.git"."96a38505d63ec9a12096991e7725b250e397af72" = "0kllakklvfrbpjlk6zi5mbxqm1prp6xdwyh2y4fw9n6c8b76is98"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 29f69d99da..bcd23f44ef 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -469,11 +469,11 @@ processChatCommand = \case DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_ StartChat subConns enableExpireCIs startXFTPWorkers -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case - Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted + Just _ -> pure $ CRChatRunning Nothing + _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted Nothing APIStopChat -> do ask >>= stopChatController - pure CRChatStopped + pure $ CRChatStopped Nothing APIActivateChat -> withUser $ \_ -> do restoreCalls withAgent foregroundAgent @@ -2814,7 +2814,7 @@ processAgentMessageNoConn = \case DISCONNECT p h -> hostEvent $ CRHostDisconnected p h DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" - SUSPENDED -> toView CRChatSuspended + SUSPENDED -> toView $ CRChatSuspended Nothing DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId where hostEvent :: ChatResponse -> m () diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 7a738512bd..7e6e60c8f5 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -49,6 +49,9 @@ data CallStateTag | CSTCallNegotiated deriving (Show, Generic) +instance FromJSON CallStateTag where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CSTCall" + instance ToJSON CallStateTag where toJSON = J.genericToJSON . enumJSON $ dropPrefix "CSTCall" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CSTCall" @@ -132,7 +135,7 @@ data RcvCallInvitation = RcvCallInvitation sharedKey :: Maybe C.Key, callTs :: UTCTime } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON RcvCallInvitation where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -157,10 +160,7 @@ data CallInvitation = CallInvitation { callType :: CallType, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic) - -instance FromJSON CallInvitation where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show, Generic, FromJSON) instance ToJSON CallInvitation where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -190,10 +190,7 @@ data CallOffer = CallOffer rtcSession :: WebRTCSession, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic) - -instance FromJSON CallOffer where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show, Generic, FromJSON) instance ToJSON CallOffer where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -203,10 +200,7 @@ data WebRTCCallOffer = WebRTCCallOffer { callType :: CallType, rtcSession :: WebRTCSession } - deriving (Eq, Show, Generic) - -instance FromJSON WebRTCCallOffer where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show, Generic, FromJSON) instance ToJSON WebRTCCallOffer where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c6c813b743..986eaf073e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1,4 +1,5 @@ {-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} @@ -12,6 +13,7 @@ {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Controller where @@ -24,11 +26,13 @@ import Control.Monad.Reader import Crypto.Random (ChaChaDRG) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?)) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) +import Data.Constraint (Dict (..)) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) @@ -64,7 +68,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) @@ -200,6 +204,9 @@ data ChatController = ChatController data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase deriving (Show, Generic) +instance FromJSON HelpSection where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "HS" + instance ToJSON HelpSection where toJSON = J.genericToJSON . enumJSON $ dropPrefix "HS" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" @@ -438,10 +445,10 @@ data ChatCommand data ChatResponse = CRActiveUser {user :: User} | CRUsersList {users :: [UserInfo]} - | CRChatStarted - | CRChatRunning - | CRChatStopped - | CRChatSuspended + | CRChatStarted {_nullary :: Maybe Int} + | CRChatRunning {_nullary :: Maybe Int} + | CRChatStopped {_nullary :: Maybe Int} + | CRChatSuspended {_nullary :: Maybe Int} | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat} @@ -605,7 +612,7 @@ data ChatResponse | CRRemoteHostDeleted {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlStarted + | CRRemoteCtrlStarted {_nullary :: Maybe Int} | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrl} -- registered fingerprint, may connect | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} @@ -629,7 +636,7 @@ data ChatResponse | CRChatError {user_ :: Maybe User, chatError :: ChatError} | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRTimedAction {action :: String, durationMilliseconds :: Int64} - deriving (Show, Generic) + deriving (Show) logResponseToFile :: ChatResponse -> Bool logResponseToFile = \case @@ -650,17 +657,12 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False -instance FromJSON ChatResponse where - parseJSON todo = pure $ CRCmdOk Nothing -- TODO: actually use the instances - -instance ToJSON ChatResponse where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" - data RemoteCtrlOOB = RemoteCtrlOOB { caFingerprint :: C.KeyHash } - deriving (Show, Generic, ToJSON) + deriving (Show, Generic, FromJSON) + +instance ToJSON RemoteCtrlOOB where toEncoding = J.genericToEncoding J.defaultOptions data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, @@ -668,14 +670,18 @@ data RemoteHostInfo = RemoteHostInfo displayName :: Text, sessionActive :: Bool } - deriving (Show, Generic, ToJSON) + deriving (Show, Generic, FromJSON) + +instance ToJSON RemoteHostInfo where toEncoding = J.genericToEncoding J.defaultOptions data RemoteCtrlInfo = RemoteCtrlInfo { remoteCtrlId :: RemoteCtrlId, displayName :: Text, sessionActive :: Bool } - deriving (Eq, Show, Generic, ToJSON) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON RemoteCtrlInfo where toEncoding = J.genericToEncoding J.defaultOptions newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -695,6 +701,9 @@ instance StrEncoding AgentQueueId where strDecode s = AgentQueueId <$> strDecode s strP = AgentQueueId <$> strP +instance FromJSON AgentQueueId where + parseJSON = strParseJSON "AgentQueueId" + instance ToJSON AgentQueueId where toJSON = strToJSON toEncoding = strToJEncoding @@ -713,12 +722,23 @@ data UserProtoServers p = UserProtoServers } deriving (Show, Generic) +instance ProtocolTypeI p => FromJSON (UserProtoServers p) where + parseJSON = J.genericParseJSON J.defaultOptions + instance ProtocolTypeI p => ToJSON (UserProtoServers p) where - toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p) +instance FromJSON AUserProtoServers where + parseJSON v = J.withObject "AUserProtoServers" parse v + where + parse o = do + AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" + case userProtocol p of + Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v + Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p + instance ToJSON AUserProtoServers where toJSON (AUPS s) = J.genericToJSON J.defaultOptions s toEncoding (AUPS s) = J.genericToEncoding J.defaultOptions s @@ -747,7 +767,7 @@ data ContactSubStatus = ContactSubStatus { contact :: Contact, contactError :: Maybe ChatError } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON ContactSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -757,7 +777,7 @@ data MemberSubStatus = MemberSubStatus { member :: GroupMember, memberError :: Maybe ChatError } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON MemberSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -767,7 +787,7 @@ data UserContactSubStatus = UserContactSubStatus { userContact :: UserContact, userContactError :: Maybe ChatError } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON UserContactSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -777,7 +797,7 @@ data PendingSubStatus = PendingSubStatus { connection :: PendingContactConnection, connError :: Maybe ChatError } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON PendingSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -789,7 +809,7 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary updateFailures :: Int, changedContacts :: [Contact] } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON UserProfileUpdateSummary where toEncoding = J.genericToEncoding J.defaultOptions @@ -825,12 +845,10 @@ data XFTPFileConfig = XFTPFileConfig defaultXFTPFileConfig :: XFTPFileConfig defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} -instance ToJSON XFTPFileConfig where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON XFTPFileConfig where toEncoding = J.genericToEncoding J.defaultOptions data NtfMsgInfo = NtfMsgInfo {msgTs :: UTCTime, msgFlags :: MsgFlags} - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON NtfMsgInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -842,7 +860,7 @@ data SwitchProgress = SwitchProgress switchPhase :: SwitchPhase, connectionStats :: ConnectionStats } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions @@ -850,7 +868,7 @@ data RatchetSyncProgress = RatchetSyncProgress { ratchetSyncStatus :: RatchetSyncState, connectionStats :: ConnectionStats } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON RatchetSyncProgress where toEncoding = J.genericToEncoding J.defaultOptions @@ -858,7 +876,7 @@ data ParsedServerAddress = ParsedServerAddress { serverAddress :: Maybe ServerAddress, parseError :: String } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions @@ -869,7 +887,7 @@ data ServerAddress = ServerAddress keyHash :: String, basicAuth :: String } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON ServerAddress where toEncoding = J.genericToEncoding J.defaultOptions @@ -893,7 +911,7 @@ data CoreVersionInfo = CoreVersionInfo simplexmqVersion :: String, simplexmqCommit :: String } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON CoreVersionInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -906,7 +924,7 @@ data SlowSQLQuery = SlowSQLQuery { query :: Text, queryStats :: SlowQueryStats } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON SlowSQLQuery where toEncoding = J.genericToEncoding J.defaultOptions @@ -919,6 +937,9 @@ data ChatError | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} deriving (Show, Exception, Generic) +instance FromJSON ChatError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "Chat" + instance ToJSON ChatError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "Chat" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat" @@ -1002,6 +1023,9 @@ data ChatErrorType | CEException {message :: String} deriving (Show, Exception, Generic) +instance FromJSON ChatErrorType where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CE" + instance ToJSON ChatErrorType where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE" @@ -1014,6 +1038,9 @@ data DatabaseError | DBErrorOpen {sqliteError :: SQLiteError} deriving (Show, Exception, Generic) +instance FromJSON DatabaseError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "DB" + instance ToJSON DatabaseError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DB" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DB" @@ -1021,6 +1048,9 @@ instance ToJSON DatabaseError where data SQLiteError = SQLiteErrorNotADatabase | SQLiteError String deriving (Show, Exception, Generic) +instance FromJSON SQLiteError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SQLite" + instance ToJSON SQLiteError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SQLite" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SQLite" @@ -1070,6 +1100,9 @@ data ArchiveError | AEImportFile {file :: String, chatError :: ChatError} deriving (Show, Exception, Generic) +instance FromJSON ArchiveError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "AE" + instance ToJSON ArchiveError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" @@ -1156,3 +1189,5 @@ withStoreCtx ctx_ action = do where handleInternal :: String -> SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index d18f28db31..64b1145539 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} @@ -10,7 +11,7 @@ module Simplex.Chat.Markdown where import Control.Applicative (optional, (<|>)) -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A @@ -56,6 +57,9 @@ data Format data SimplexLinkType = XLContact | XLInvitation | XLGroup deriving (Eq, Show, Generic) +instance FromJSON SimplexLinkType where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "XL" + instance ToJSON SimplexLinkType where toJSON = J.genericToJSON . enumJSON $ dropPrefix "XL" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "XL" @@ -66,6 +70,9 @@ colored = Colored . FormatColor markdown :: Format -> Text -> Markdown markdown = Markdown . Just +instance FromJSON Format where + parseJSON = J.genericParseJSON $ sumTypeJSON fstToLower + instance ToJSON Format where toJSON = J.genericToJSON $ sumTypeJSON fstToLower toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower @@ -91,6 +98,18 @@ instance IsString Markdown where fromString = unmarked . T.pack newtype FormatColor = FormatColor Color deriving (Eq, Show) +instance FromJSON FormatColor where + parseJSON = J.withText "FormatColor" $ fmap FormatColor . \case + "red" -> pure Red + "green" -> pure Green + "blue" -> pure Blue + "yellow" -> pure Yellow + "cyan" -> pure Cyan + "magenta" -> pure Magenta + "black" -> pure Black + "white" -> pure White + unexpected -> fail $ "unexpected FormatColor: " <> show unexpected + instance ToJSON FormatColor where toJSON (FormatColor c) = case c of Red -> "red" @@ -103,7 +122,7 @@ instance ToJSON FormatColor where White -> "white" data FormattedText = FormattedText {format :: Maybe Format, text :: Text} - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON FormattedText where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -129,7 +148,7 @@ parseMaybeMarkdownList s | otherwise = Just . reverse $ foldl' acc [] ml where ml = intercalate ["\n"] . map (markdownToList . parseMarkdown) $ T.lines s - acc [] m = [m] + acc [] m = [m] acc ms@(FormattedText f t : ms') ft@(FormattedText f' t') | f == f' = FormattedText f (t <> t') : ms' | otherwise = ft : ms diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 79463d2107..b9ce953731 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -17,7 +17,7 @@ module Simplex.Chat.Messages where import Control.Applicative ((<|>)) -import Data.Aeson (FromJSON, ToJSON) +import Data.Aeson (FromJSON, ToJSON, (.:)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Attoparsec.ByteString.Char8 as A @@ -66,6 +66,9 @@ chatNameStr (ChatName cType name) = chatTypeStr cType <> T.unpack name data ChatRef = ChatRef ChatType Int64 deriving (Eq, Show, Ord) +instance FromJSON ChatType where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CT" + instance ToJSON ChatType where toJSON = J.genericToJSON . enumJSON $ dropPrefix "CT" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CT" @@ -110,10 +113,16 @@ data JSONChatInfo | JCInfoContactConnection {contactConnection :: PendingContactConnection} deriving (Generic) +instance FromJSON JSONChatInfo where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCInfo" + instance ToJSON JSONChatInfo where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCInfo" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCInfo" +instance ChatTypeI c => FromJSON (ChatInfo c) where + parseJSON v = (\(AChatInfo _ c) -> checkChatType c) <$?> J.parseJSON v + instance ToJSON (ChatInfo c) where toJSON = J.toJSON . jsonChatInfo toEncoding = J.toEncoding . jsonChatInfo @@ -125,10 +134,20 @@ jsonChatInfo = \case ContactRequest g -> JCInfoContactRequest g ContactConnection c -> JCInfoContactConnection c -data AChatInfo = forall c. AChatInfo (SChatType c) (ChatInfo c) +data AChatInfo = forall c. ChatTypeI c => AChatInfo (SChatType c) (ChatInfo c) deriving instance Show AChatInfo +jsonAChatInfo :: JSONChatInfo -> AChatInfo +jsonAChatInfo = \case + JCInfoDirect c -> AChatInfo SCTDirect $ DirectChat c + JCInfoGroup g -> AChatInfo SCTGroup $ GroupChat g + JCInfoContactRequest g -> AChatInfo SCTContactRequest $ ContactRequest g + JCInfoContactConnection c -> AChatInfo SCTContactConnection $ ContactConnection c + +instance FromJSON AChatInfo where + parseJSON v = jsonAChatInfo <$> J.parseJSON v + instance ToJSON AChatInfo where toJSON (AChatInfo _ c) = J.toJSON c toEncoding (AChatInfo _ c) = J.toEncoding c @@ -144,7 +163,10 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem } deriving (Show, Generic) -instance MsgDirectionI d => ToJSON (ChatItem c d) where +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where + parseJSON = J.genericParseJSON J.defaultOptions + +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (ChatItem c d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -156,6 +178,16 @@ data CIDirection (c :: ChatType) (d :: MsgDirection) where deriving instance Show (CIDirection c d) +data CCIDirection c = forall d. MsgDirectionI d => CCID (SMsgDirection d) (CIDirection c d) + +instance ChatTypeI c => FromJSON (CCIDirection c) where + parseJSON v = (\(ACID _ d x) -> checkChatType (CCID d x)) <$?> J.parseJSON v + +data ACIDirection = forall c d. (ChatTypeI c, MsgDirectionI d) => ACID (SChatType c) (SMsgDirection d) (CIDirection c d) + +instance FromJSON ACIDirection where + parseJSON v = jsonACIDirection <$> J.parseJSON v + data JSONCIDirection = JCIDirectSnd | JCIDirectRcv @@ -163,10 +195,16 @@ data JSONCIDirection | JCIGroupRcv {groupMember :: GroupMember} deriving (Generic, Show) +instance FromJSON JSONCIDirection where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" + instance ToJSON JSONCIDirection where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where + parseJSON v = (\(CCID _ x') -> checkDirection x') <$?> J.parseJSON v + instance ToJSON (CIDirection c d) where toJSON = J.toJSON . jsonCIDirection toEncoding = J.toEncoding . jsonCIDirection @@ -178,8 +216,15 @@ jsonCIDirection = \case CIGroupSnd -> JCIGroupSnd CIGroupRcv m -> JCIGroupRcv m +jsonACIDirection :: JSONCIDirection -> ACIDirection +jsonACIDirection = \case + JCIDirectSnd -> ACID SCTDirect SMDSnd CIDirectSnd + JCIDirectRcv -> ACID SCTDirect SMDRcv CIDirectRcv + JCIGroupSnd -> ACID SCTGroup SMDSnd CIGroupSnd + JCIGroupRcv m -> ACID SCTGroup SMDRcv $ CIGroupRcv m + data CIReactionCount = CIReactionCount {reaction :: MsgReaction, userReacted :: Bool, totalReacted :: Int} - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON CIReactionCount where toEncoding = J.genericToEncoding J.defaultOptions @@ -187,7 +232,15 @@ data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (Cha deriving instance Show (CChatItem c) -instance ToJSON (CChatItem c) where +instance forall c. ChatTypeI c => FromJSON (CChatItem c) where + parseJSON v = J.withObject "CChatItem" parse v + where + parse o = do + CCID d (_ :: CIDirection c d) <- o .: "chatDir" + ci <- J.parseJSON @(ChatItem c d) v + pure $ CChatItem d ci + +instance ChatTypeI c => ToJSON (CChatItem c) where toJSON (CChatItem _ ci) = J.toJSON ci toEncoding (CChatItem _ ci) = J.toEncoding ci @@ -279,14 +332,19 @@ data Chat c = Chat } deriving (Show, Generic) -instance ToJSON (Chat c) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions +instance ChatTypeI c => ToJSON (Chat c) where toEncoding = J.genericToEncoding J.defaultOptions -data AChat = forall c. AChat (SChatType c) (Chat c) +data AChat = forall c. ChatTypeI c => AChat (SChatType c) (Chat c) deriving instance Show AChat +instance FromJSON AChat where + parseJSON = J.withObject "AChat" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + chatItems <- o .: "chatItems" + chatStats <- o .: "chatStats" + pure $ AChat c Chat {chatInfo, chatItems, chatStats} + instance ToJSON AChat where toJSON (AChat _ c) = J.toJSON c toEncoding (AChat _ c) = J.toEncoding c @@ -296,17 +354,21 @@ data ChatStats = ChatStats minUnreadItemId :: ChatItemId, unreadChat :: Bool } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) -instance ToJSON ChatStats where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON ChatStats where toEncoding = J.genericToEncoding J.defaultOptions -- | type to show a mix of messages from multiple chats -data AChatItem = forall c d. MsgDirectionI d => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) +data AChatItem = forall c d. (ChatTypeI c, MsgDirectionI d) => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) deriving instance Show AChatItem +instance FromJSON AChatItem where + parseJSON = J.withObject "AChatItem" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + CChatItem d chatItem <- o .: "chatItem" + pure $ AChatItem c d chatInfo chatItem + instance ToJSON AChatItem where toJSON (AChatItem _ _ chat item) = J.toJSON $ JSONAnyChatItem chat item toEncoding (AChatItem _ _ chat item) = J.toEncoding $ JSONAnyChatItem chat item @@ -330,7 +392,7 @@ updateFileStatus ci@ChatItem {file} status = case file of Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}} Nothing -> ci -instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions @@ -349,7 +411,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta c d mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs createdAt updatedAt = @@ -358,13 +420,13 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item _ -> False in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, createdAt, updatedAt} -instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions +instance ChatTypeI c => ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions data CITimed = CITimed { ttl :: Int, -- seconds deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON CITimed where toEncoding = J.genericToEncoding J.defaultOptions @@ -402,6 +464,9 @@ data CIQuote (c :: ChatType) = CIQuote } deriving (Show, Generic) +instance ChatTypeI c => FromJSON (CIQuote c) where + parseJSON = J.genericParseJSON J.defaultOptions + instance ToJSON (CIQuote c) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -414,24 +479,39 @@ data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction } deriving (Show, Generic) -instance ToJSON (CIReaction c d) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIReaction c d) where + parseJSON = J.genericParseJSON J.defaultOptions -data ACIReaction = forall c d. ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d) +instance ChatTypeI c => ToJSON (CIReaction c d) where + toEncoding = J.genericToEncoding J.defaultOptions + +data AnyCIReaction = forall c d. ChatTypeI c => ACIR (SChatType c) (SMsgDirection d) (CIReaction c d) + +instance FromJSON AnyCIReaction where + parseJSON v = J.withObject "AnyCIReaction" parse v + where + parse o = do + ACID c d (_ :: CIDirection c d) <- o .: "chatDir" + ACIR c d <$> J.parseJSON @(CIReaction c d) v + +data ACIReaction = forall c d. ChatTypeI c => ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d) deriving instance Show ACIReaction +instance FromJSON ACIReaction where + parseJSON = J.withObject "ACIReaction" $ \o -> do + ACIR c d reaction <- o .: "chatReaction" + cInfo <- o .: "chatInfo" + pure $ ACIReaction c d cInfo reaction + instance ToJSON ACIReaction where - toJSON (ACIReaction _ _ chat reaction) = J.toJSON $ JSONCIReaction chat reaction - toEncoding (ACIReaction _ _ chat reaction) = J.toEncoding $ JSONCIReaction chat reaction + toJSON (ACIReaction _ _ cInfo reaction) = J.toJSON $ JSONCIReaction cInfo reaction + toEncoding (ACIReaction _ _ cInfo reaction) = J.toEncoding $ JSONCIReaction cInfo reaction data JSONCIReaction c d = JSONCIReaction {chatInfo :: ChatInfo c, chatReaction :: CIReaction c d} deriving (Generic) -instance ToJSON (JSONCIReaction c d) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions +instance ChatTypeI c => ToJSON (JSONCIReaction c d) where toEncoding = J.genericToEncoding J.defaultOptions data CIQDirection (c :: ChatType) where CIQDirectSnd :: CIQDirection 'CTDirect @@ -441,6 +521,11 @@ data CIQDirection (c :: ChatType) where deriving instance Show (CIQDirection c) +data ACIQDirection = forall c. ChatTypeI c => ACIQDirection (SChatType c) (CIQDirection c) + +instance ChatTypeI c => FromJSON (CIQDirection c) where + parseJSON v = (\(ACIQDirection _ x) -> checkChatType x) . jsonACIQDirection <$?> J.parseJSON v + instance ToJSON (CIQDirection c) where toJSON = J.toJSON . jsonCIQDirection toEncoding = J.toEncoding . jsonCIQDirection @@ -453,6 +538,14 @@ jsonCIQDirection = \case CIQGroupRcv (Just m) -> Just $ JCIGroupRcv m CIQGroupRcv Nothing -> Nothing +jsonACIQDirection :: Maybe JSONCIDirection -> ACIQDirection +jsonACIQDirection = \case + Just JCIDirectSnd -> ACIQDirection SCTDirect CIQDirectSnd + Just JCIDirectRcv -> ACIQDirection SCTDirect CIQDirectRcv + Just JCIGroupSnd -> ACIQDirection SCTGroup CIQGroupSnd + Just (JCIGroupRcv m) -> ACIQDirection SCTGroup $ CIQGroupRcv (Just m) + Nothing -> ACIQDirection SCTGroup $ CIQGroupRcv Nothing + quoteMsgDirection :: CIQDirection c -> MsgDirection quoteMsgDirection = \case CIQDirectSnd -> MDSnd @@ -470,6 +563,9 @@ data CIFile (d :: MsgDirection) = CIFile } deriving (Show, Generic) +instance MsgDirectionI d => FromJSON (CIFile d) where + parseJSON = J.genericParseJSON J.defaultOptions + instance MsgDirectionI d => ToJSON (CIFile d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -481,6 +577,9 @@ instance FromField FileProtocol where fromField = fromTextField_ textDecode instance ToField FileProtocol where toField = toField . textEncode +instance FromJSON FileProtocol where + parseJSON = textParseJSON "FileProtocol" + instance ToJSON FileProtocol where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -527,6 +626,9 @@ ciFileEnded = \case CIFSRcvError -> True CIFSInvalid {} -> True +instance MsgDirectionI d => FromJSON (CIFileStatus d) where + parseJSON v = (\(AFS _ s) -> checkDirection s) . aciFileStatusJSON <$?> J.parseJSON v + instance ToJSON (CIFileStatus d) where toJSON = J.toJSON . jsonCIFileStatus toEncoding = J.toEncoding . jsonCIFileStatus @@ -594,6 +696,9 @@ data JSONCIFileStatus | JCIFSInvalid {text :: Text} deriving (Generic) +instance FromJSON JSONCIFileStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCIFS" + instance ToJSON JSONCIFileStatus where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIFS" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIFS" @@ -658,6 +763,9 @@ deriving instance Eq (CIStatus d) deriving instance Show (CIStatus d) +instance MsgDirectionI d => FromJSON (CIStatus d) where + parseJSON v = (\(ACIStatus _ s) -> checkDirection s) . jsonACIStatus <$?> J.parseJSON v + instance ToJSON (CIStatus d) where toJSON = J.toJSON . jsonCIStatus toEncoding = J.toEncoding . jsonCIStatus @@ -712,6 +820,9 @@ data JSONCIStatus | JCISInvalid {text :: Text} deriving (Show, Generic) +instance FromJSON JSONCIStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCIS" + instance ToJSON JSONCIStatus where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIS" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIS" @@ -727,6 +838,17 @@ jsonCIStatus = \case CISRcvRead -> JCISRcvRead CISInvalid text -> JCISInvalid text +jsonACIStatus :: JSONCIStatus -> ACIStatus +jsonACIStatus = \case + JCISSndNew -> ACIStatus SMDSnd CISSndNew + JCISSndSent sndProgress -> ACIStatus SMDSnd $ CISSndSent sndProgress + JCISSndRcvd msgRcptStatus sndProgress -> ACIStatus SMDSnd $ CISSndRcvd msgRcptStatus sndProgress + JCISSndErrorAuth -> ACIStatus SMDSnd CISSndErrorAuth + JCISSndError e -> ACIStatus SMDSnd $ CISSndError e + JCISRcvNew -> ACIStatus SMDRcv CISRcvNew + JCISRcvRead -> ACIStatus SMDRcv CISRcvRead + JCISInvalid text -> ACIStatus SMDSnd $ CISInvalid text + ciStatusNew :: forall d. MsgDirectionI d => CIStatus d ciStatusNew = case msgDirection @d of SMDSnd -> CISSndNew @@ -757,6 +879,9 @@ data SndCIStatusProgress | SSPComplete deriving (Eq, Show, Generic) +instance FromJSON SndCIStatusProgress where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SSP" + instance ToJSON SndCIStatusProgress where toJSON = J.genericToJSON . enumJSON $ dropPrefix "SSP" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "SSP" @@ -796,6 +921,8 @@ instance TestEquality SChatType where testEquality SCTContactConnection SCTContactConnection = Just Refl testEquality _ _ = Nothing +data AChatType = forall c. ChatTypeI c => ACT (SChatType c) + class ChatTypeI (c :: ChatType) where chatTypeI :: SChatType c @@ -803,6 +930,36 @@ instance ChatTypeI 'CTDirect where chatTypeI = SCTDirect instance ChatTypeI 'CTGroup where chatTypeI = SCTGroup +instance ChatTypeI 'CTContactRequest where chatTypeI = SCTContactRequest + +instance ChatTypeI 'CTContactConnection where chatTypeI = SCTContactConnection + +instance ChatTypeI c => FromJSON (SChatType c) where + parseJSON v = (\(ACT t) -> checkChatType t) . aChatType <$?> J.parseJSON v + +instance ToJSON (SChatType c) where + toJSON = J.toJSON . toChatType + toEncoding = J.toEncoding . toChatType + +toChatType :: SChatType c -> ChatType +toChatType = \case + SCTDirect -> CTDirect + SCTGroup -> CTGroup + SCTContactRequest -> CTContactRequest + SCTContactConnection -> CTContactConnection + +aChatType :: ChatType -> AChatType +aChatType = \case + CTDirect -> ACT SCTDirect + CTGroup -> ACT SCTGroup + CTContactRequest -> ACT SCTContactRequest + CTContactConnection -> ACT SCTContactConnection + +checkChatType :: forall t c c'. (ChatTypeI c, ChatTypeI c') => t c' -> Either String (t c) +checkChatType x = case testEquality (chatTypeI @c) (chatTypeI @c') of + Just Refl -> Right x + Nothing -> Left "bad chat type" + data NewMessage e = NewMessage { chatMsgEvent :: ChatMsgEvent e, msgBody :: MsgBody @@ -920,35 +1077,43 @@ msgDeliveryStatusT' s = Just Refl -> Just st _ -> Nothing -checkDirection :: forall t d d'. (MsgDirectionI d, MsgDirectionI d') => t d' -> Either String (t d) -checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of - Just Refl -> Right x - Nothing -> Left "bad direction" - data CIDeleted (c :: ChatType) where CIDeleted :: Maybe UTCTime -> CIDeleted c CIModerated :: Maybe UTCTime -> GroupMember -> CIDeleted 'CTGroup deriving instance Show (CIDeleted c) -instance ToJSON (CIDeleted d) where +data ACIDeleted = forall c. ChatTypeI c => ACIDeleted (SChatType c) (CIDeleted c) + +instance ChatTypeI c => FromJSON (CIDeleted c) where + parseJSON v = (\(ACIDeleted _ x) -> checkChatType x) . jsonACIDeleted <$?> J.parseJSON v + +instance ChatTypeI c => ToJSON (CIDeleted c) where toJSON = J.toJSON . jsonCIDeleted toEncoding = J.toEncoding . jsonCIDeleted data JSONCIDeleted - = JCIDDeleted {deletedTs :: Maybe UTCTime} + = JCIDDeleted {deletedTs :: Maybe UTCTime, chatType :: ChatType} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} deriving (Show, Generic) +instance FromJSON JSONCIDeleted where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCID" + instance ToJSON JSONCIDeleted where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCID" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCID" -jsonCIDeleted :: CIDeleted d -> JSONCIDeleted +jsonCIDeleted :: forall d. ChatTypeI d => CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case - CIDeleted ts -> JCIDDeleted ts + CIDeleted ts -> JCIDDeleted ts (toChatType $ chatTypeI @d) CIModerated ts m -> JCIDModerated ts m +jsonACIDeleted :: JSONCIDeleted -> ACIDeleted +jsonACIDeleted = \case + JCIDDeleted ts cType -> case aChatType cType of ACT c -> ACIDeleted c $ CIDeleted ts + JCIDModerated ts m -> ACIDeleted SCTGroup (CIModerated ts m) + itemDeletedTs :: CIDeleted d -> Maybe UTCTime itemDeletedTs = \case CIDeleted ts -> ts @@ -958,7 +1123,7 @@ data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], memberDeliveryStatuses :: Maybe [MemberDeliveryStatus] } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON ChatItemInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -969,7 +1134,7 @@ data ChatItemVersion = ChatItemVersion itemVersionTs :: UTCTime, createdAt :: UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON ChatItemVersion where toEncoding = J.genericToEncoding J.defaultOptions @@ -990,7 +1155,7 @@ data MemberDeliveryStatus = MemberDeliveryStatus { groupMemberId :: GroupMemberId, memberDeliveryStatus :: CIStatus 'MDSnd } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON MemberDeliveryStatus where toEncoding = J.genericToEncoding J.defaultOptions diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 9abc8e4644..8f9a453bde 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -9,12 +9,14 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -34,7 +36,7 @@ import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) -import Simplex.Messaging.Util (safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow, (<$?>)) data MsgDirection = MDRcv | MDSnd deriving (Eq, Show, Generic) @@ -69,6 +71,13 @@ instance TestEquality SMsgDirection where testEquality SMDSnd SMDSnd = Just Refl testEquality _ _ = Nothing +instance MsgDirectionI d => FromJSON (SMsgDirection d) where + parseJSON v = (\(AMsgDirection d) -> checkDirection d) . fromMsgDirection <$?> J.parseJSON v + +instance ToJSON (SMsgDirection d) where + toJSON = J.toJSON . toMsgDirection + toEncoding = J.toEncoding . toMsgDirection + instance ToField (SMsgDirection d) where toField = toField . msgDirectionInt . toMsgDirection data AMsgDirection = forall d. MsgDirectionI d => AMsgDirection (SMsgDirection d) @@ -92,6 +101,11 @@ instance MsgDirectionI 'MDRcv where msgDirection = SMDRcv instance MsgDirectionI 'MDSnd where msgDirection = SMDSnd +checkDirection :: forall t d d'. (MsgDirectionI d, MsgDirectionI d') => t d' -> Either String (t d) +checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of + Just Refl -> Right x + Nothing -> Left "bad direction" + msgDirectionInt :: MsgDirection -> Int msgDirectionInt = \case MDRcv -> 0 @@ -481,27 +495,10 @@ msgDirToModeratedContent_ = \case ciModeratedText :: Text ciModeratedText = "moderated" --- platform independent -instance MsgDirectionI d => ToField (CIContent d) where - toField = toField . encodeJSON . dbJsonCIContent - --- platform specific -instance MsgDirectionI d => ToJSON (CIContent d) where - toJSON = J.toJSON . jsonCIContent - toEncoding = J.toEncoding . jsonCIContent - data ACIContent = forall d. MsgDirectionI d => ACIContent (SMsgDirection d) (CIContent d) deriving instance Show ACIContent --- platform independent -dbParseACIContent :: Text -> Either String ACIContent -dbParseACIContent = fmap aciContentDBJSON . J.eitherDecodeStrict' . encodeUtf8 - --- platform specific -instance FromJSON ACIContent where - parseJSON = fmap aciContentJSON . J.parseJSON - -- platform specific data JSONCIContent = JCISndMsgContent {msgContent :: MsgContent} @@ -527,17 +524,9 @@ data JSONCIContent | JCISndGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int} | JCIRcvChatFeatureRejected {feature :: ChatFeature} | JCIRcvGroupFeatureRejected {groupFeature :: GroupFeature} - | JCISndModerated - | JCIRcvModerated + | JCISndModerated {_nullary :: Maybe Int} + | JCIRcvModerated {_nullary :: Maybe Int} | JCIInvalidJSON {direction :: MsgDirection, json :: Text} - deriving (Generic) - -instance FromJSON JSONCIContent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" - -instance ToJSON JSONCIContent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" jsonCIContent :: forall d. MsgDirectionI d => CIContent d -> JSONCIContent jsonCIContent = \case @@ -564,8 +553,8 @@ jsonCIContent = \case CISndGroupFeature groupFeature preference param -> JCISndGroupFeature {groupFeature, preference, param} CIRcvChatFeatureRejected feature -> JCIRcvChatFeatureRejected {feature} CIRcvGroupFeatureRejected groupFeature -> JCIRcvGroupFeatureRejected {groupFeature} - CISndModerated -> JCISndModerated - CIRcvModerated -> JCISndModerated + CISndModerated -> JCISndModerated Nothing + CIRcvModerated -> JCISndModerated Nothing CIInvalidJSON json -> JCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentJSON :: JSONCIContent -> ACIContent @@ -593,8 +582,8 @@ aciContentJSON = \case JCISndGroupFeature {groupFeature, preference, param} -> ACIContent SMDSnd $ CISndGroupFeature groupFeature preference param JCIRcvChatFeatureRejected {feature} -> ACIContent SMDRcv $ CIRcvChatFeatureRejected feature JCIRcvGroupFeatureRejected {groupFeature} -> ACIContent SMDRcv $ CIRcvGroupFeatureRejected groupFeature - JCISndModerated -> ACIContent SMDSnd CISndModerated - JCIRcvModerated -> ACIContent SMDRcv CIRcvModerated + JCISndModerated _ -> ACIContent SMDSnd CISndModerated + JCIRcvModerated _ -> ACIContent SMDRcv CIRcvModerated JCIInvalidJSON dir json -> case fromMsgDirection dir of AMsgDirection d -> ACIContent d $ CIInvalidJSON json @@ -623,17 +612,9 @@ data DBJSONCIContent | DBJCISndGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int} | DBJCIRcvChatFeatureRejected {feature :: ChatFeature} | DBJCIRcvGroupFeatureRejected {groupFeature :: GroupFeature} - | DBJCISndModerated - | DBJCIRcvModerated + | DBJCISndModerated {_nullary :: Maybe Int} + | DBJCIRcvModerated {_nullary :: Maybe Int} | DBJCIInvalidJSON {direction :: MsgDirection, json :: Text} - deriving (Generic) - -instance FromJSON DBJSONCIContent where - parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "DBJCI" - -instance ToJSON DBJSONCIContent where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "DBJCI" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "DBJCI" dbJsonCIContent :: forall d. MsgDirectionI d => CIContent d -> DBJSONCIContent dbJsonCIContent = \case @@ -660,8 +641,8 @@ dbJsonCIContent = \case CISndGroupFeature groupFeature preference param -> DBJCISndGroupFeature {groupFeature, preference, param} CIRcvChatFeatureRejected feature -> DBJCIRcvChatFeatureRejected {feature} CIRcvGroupFeatureRejected groupFeature -> DBJCIRcvGroupFeatureRejected {groupFeature} - CISndModerated -> DBJCISndModerated - CIRcvModerated -> DBJCIRcvModerated + CISndModerated -> DBJCISndModerated Nothing + CIRcvModerated -> DBJCIRcvModerated Nothing CIInvalidJSON json -> DBJCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentDBJSON :: DBJSONCIContent -> ACIContent @@ -689,8 +670,8 @@ aciContentDBJSON = \case DBJCISndGroupFeature {groupFeature, preference, param} -> ACIContent SMDSnd $ CISndGroupFeature groupFeature preference param DBJCIRcvChatFeatureRejected {feature} -> ACIContent SMDRcv $ CIRcvChatFeatureRejected feature DBJCIRcvGroupFeatureRejected {groupFeature} -> ACIContent SMDRcv $ CIRcvGroupFeatureRejected groupFeature - DBJCISndModerated -> ACIContent SMDSnd CISndModerated - DBJCIRcvModerated -> ACIContent SMDRcv CIRcvModerated + DBJCISndModerated _ -> ACIContent SMDSnd CISndModerated + DBJCIRcvModerated _ -> ACIContent SMDRcv CIRcvModerated DBJCIInvalidJSON dir json -> case fromMsgDirection dir of AMsgDirection d -> ACIContent d $ CIInvalidJSON json @@ -703,14 +684,7 @@ data CICallStatus | CISCallProgress | CISCallEnded | CISCallError - deriving (Show, Generic) - -instance FromJSON CICallStatus where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CISCall" - -instance ToJSON CICallStatus where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CISCall" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CISCall" + deriving (Show) ciCallInfoText :: CICallStatus -> Int -> Text ciCallInfoText status duration = case status of @@ -722,3 +696,31 @@ ciCallInfoText status duration = case status of CISCallProgress -> "in progress " <> durationText duration CISCallEnded -> "ended " <> durationText duration CISCallError -> "error" + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CISCall") ''CICallStatus) + +-- platform specific +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIContent) + +-- platform independent +$(JQ.deriveJSON (singleFieldJSON $ dropPrefix "DBJCI") ''DBJSONCIContent) + +-- platform independent +instance MsgDirectionI d => ToField (CIContent d) where + toField = toField . encodeJSON . dbJsonCIContent + +-- platform specific +instance MsgDirectionI d => ToJSON (CIContent d) where + toJSON = J.toJSON . jsonCIContent + toEncoding = J.toEncoding . jsonCIContent + +instance MsgDirectionI d => FromJSON (CIContent d) where + parseJSON v = (\(ACIContent _ c) -> checkDirection c) <$?> J.parseJSON v + +-- platform independent +dbParseACIContent :: Text -> Either String ACIContent +dbParseACIContent = fmap aciContentDBJSON . J.eitherDecodeStrict' . encodeUtf8 + +-- platform specific +instance FromJSON ACIContent where + parseJSON = fmap aciContentJSON . J.parseJSON diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index bbdddf8ce0..cb937441f9 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -72,6 +72,9 @@ data ConnectionEntity | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} deriving (Eq, Show, Generic) +instance FromJSON ConnectionEntity where + parseJSON = J.genericParseJSON $ sumTypeJSON fstToLower + instance ToJSON ConnectionEntity where toJSON = J.genericToJSON $ sumTypeJSON fstToLower toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 936c750c6c..8f7a3b4f4c 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -201,7 +201,7 @@ startRemoteCtrl = chatWriteVar remoteCtrlSession Nothing toView $ CRRemoteCtrlStopped {remoteCtrlId} chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted} - pure CRRemoteCtrlStarted + pure $ CRRemoteCtrlStarted Nothing discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () discoverRemoteCtrls discovered = Discovery.openListener >>= go diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index b66e9a6253..f13c3c84ea 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,14 +1,14 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.Remote.Types where import Control.Concurrent.Async (Async) -import Data.Aeson (ToJSON (..)) +import qualified Data.Aeson.TH as J import Data.Int (Int64) import Data.Text (Text) -import GHC.Generics (Generic) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport.Client (TransportHost) @@ -37,7 +37,9 @@ data RemoteCtrl = RemoteCtrl fingerprint :: C.KeyHash, accepted :: Maybe Bool } - deriving (Show, Generic, ToJSON) + deriving (Show) + +$(J.deriveJSON J.defaultOptions ''RemoteCtrl) data RemoteHostSession = RemoteHostSessionStarting diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index e521cb43cf..d005c3893e 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} @@ -59,7 +60,7 @@ where import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import Data.Functor (($>)) import Data.Int (Int64) @@ -398,7 +399,7 @@ data UserContactLink = UserContactLink { connReqContact :: ConnReqContact, autoAccept :: Maybe AutoAccept } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON UserContactLink where toEncoding = J.genericToEncoding J.defaultOptions @@ -406,7 +407,7 @@ data AutoAccept = AutoAccept { acceptIncognito :: IncognitoEnabled, autoReply :: Maybe MsgContent } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON AutoAccept where toEncoding = J.genericToEncoding J.defaultOptions diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 4dc4f6e82d..64634dd29d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -16,7 +16,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG, randomBytesGenerate) -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) @@ -102,6 +102,9 @@ data StoreError | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} deriving (Show, Exception, Generic) +instance FromJSON StoreError where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SE" + instance ToJSON StoreError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SE" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 0b143a7574..088f23e056 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -15,6 +15,7 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilyDependencies #-} {-# LANGUAGE UndecidableInstances #-} @@ -25,9 +26,10 @@ module Simplex.Chat.Types where import Crypto.Number.Serialize (os2ip) -import Data.Aeson (FromJSON (..), ToJSON (..), (.=)) +import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString, pack, unpack) @@ -112,18 +114,14 @@ data User = User sendRcptsContacts :: Bool, sendRcptsSmallGroups :: Bool } - deriving (Show, Generic, FromJSON) - -instance ToJSON User where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data NewUser = NewUser { profile :: Maybe Profile, sameServers :: Bool, pastTimestamp :: Bool } - deriving (Show, Generic, FromJSON) + deriving (Show) newtype B64UrlByteString = B64UrlByteString ByteString deriving (Eq, Show) @@ -144,19 +142,13 @@ instance ToJSON B64UrlByteString where toEncoding = strToJEncoding data UserPwdHash = UserPwdHash {hash :: B64UrlByteString, salt :: B64UrlByteString} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON UserPwdHash where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data UserInfo = UserInfo { user :: User, unreadCount :: Int } - deriving (Show, Generic, FromJSON) - -instance ToJSON UserInfo where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) type ContactId = Int64 @@ -179,11 +171,7 @@ data Contact = Contact contactGroupMemberId :: Maybe GroupMemberId, contactGrpInvSent :: Bool } - deriving (Eq, Show, Generic) - -instance ToJSON Contact where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) contactConn :: Contact -> Connection contactConn Contact {activeConn} = activeConn @@ -221,6 +209,9 @@ instance FromField ContactStatus where fromField = fromTextField_ textDecode instance ToField ContactStatus where toField = toField . textEncode +instance FromJSON ContactStatus where + parseJSON = textParseJSON "ContactStatus" + instance ToJSON ContactStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -240,9 +231,7 @@ data ContactRef = ContactRef agentConnId :: AgentConnId, localDisplayName :: ContactName } - deriving (Eq, Show, Generic) - -instance ToJSON ContactRef where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data ContactOrGroupMember = CGMContact Contact | CGMGroupMember GroupInfo GroupMember deriving (Show) @@ -262,15 +251,13 @@ data UserContact = UserContact connReqContact :: ConnReqContact, groupId :: Maybe GroupId } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON UserContact where toEncoding = J.genericToEncoding J.defaultOptions userContactGroupId :: UserContact -> Maybe GroupId userContactGroupId UserContact {groupId} = groupId -instance ToJSON UserContact where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions - data UserContactRequest = UserContactRequest { contactRequestId :: Int64, agentInvitationId :: AgentInvId, @@ -284,7 +271,7 @@ data UserContactRequest = UserContactRequest updatedAt :: UTCTime, xContactId :: Maybe XContactId } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON UserContactRequest where toEncoding = J.genericToEncoding J.defaultOptions @@ -341,7 +328,7 @@ optionalFullName displayName fullName | otherwise = " (" <> fullName <> ")" data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON Group where toEncoding = J.genericToEncoding J.defaultOptions @@ -359,7 +346,7 @@ data GroupInfo = GroupInfo updatedAt :: UTCTime, chatTs :: Maybe UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -369,7 +356,7 @@ groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary { currentMembers :: Int } - deriving (Show, Generic) + deriving (Show, Generic, FromJSON) instance ToJSON GroupSummary where toEncoding = J.genericToEncoding J.defaultOptions @@ -639,7 +626,7 @@ data GroupMember = GroupMember memberContactProfileId :: ProfileId, activeConn :: Maybe Connection } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON GroupMember where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -710,6 +697,9 @@ instance ToJSON MemberId where data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show, Generic) +instance FromJSON InvitedBy where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "IB" + instance ToJSON InvitedBy where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" @@ -803,6 +793,9 @@ instance FromField GroupMemberCategory where fromField = fromTextField_ textDeco instance ToField GroupMemberCategory where toField = toField . textEncode +instance FromJSON GroupMemberCategory where + parseJSON = textParseJSON "GroupMemberCategory" + instance ToJSON GroupMemberCategory where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -840,6 +833,9 @@ instance FromField GroupMemberStatus where fromField = fromTextField_ textDecode instance ToField GroupMemberStatus where toField = toField . textEncode +instance FromJSON GroupMemberStatus where + parseJSON = textParseJSON "GroupMemberStatus" + instance ToJSON GroupMemberStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -931,7 +927,7 @@ data SndFileTransfer = SndFileTransfer fileDescrId :: Maybe Int64, fileInline :: Maybe InlineFileMode } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON SndFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions @@ -997,7 +993,7 @@ instance FromField InlineFileMode where fromField = fromTextField_ textDecode instance ToField InlineFileMode where toField = toField . textEncode instance FromJSON InlineFileMode where - parseJSON = J.withText "InlineFileMode" $ maybe (fail "bad InlineFileMode") pure . textDecode + parseJSON = textParseJSON "InlineFileMode" instance ToJSON InlineFileMode where toJSON = J.String . textEncode @@ -1017,7 +1013,7 @@ data RcvFileTransfer = RcvFileTransfer -- SMP files are encrypted after all chunks are received cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions @@ -1026,7 +1022,7 @@ data XFTPRcvFile = XFTPRcvFile agentRcvFileId :: Maybe AgentRcvFileId, agentRcvFileDeleted :: Bool } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON XFTPRcvFile where toEncoding = J.genericToEncoding J.defaultOptions @@ -1036,7 +1032,7 @@ data RcvFileDescr = RcvFileDescr fileDescrPartNo :: Int, fileDescrComplete :: Bool } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON RcvFileDescr where toEncoding = J.genericToEncoding J.defaultOptions @@ -1048,6 +1044,9 @@ data RcvFileStatus | RFSCancelled (Maybe RcvFileInfo) deriving (Eq, Show, Generic) +instance FromJSON RcvFileStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" + instance ToJSON RcvFileStatus where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" @@ -1065,7 +1064,7 @@ data RcvFileInfo = RcvFileInfo connId :: Maybe Int64, agentConnId :: Maybe AgentConnId } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -1094,6 +1093,9 @@ instance StrEncoding AgentConnId where strDecode s = AgentConnId <$> strDecode s strP = AgentConnId <$> strP +instance FromJSON AgentConnId where + parseJSON = strParseJSON "AgentConnId" + instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1110,6 +1112,9 @@ instance StrEncoding AgentSndFileId where strDecode s = AgentSndFileId <$> strDecode s strP = AgentSndFileId <$> strP +instance FromJSON AgentSndFileId where + parseJSON = strParseJSON "AgentSndFileId" + instance ToJSON AgentSndFileId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1126,6 +1131,9 @@ instance StrEncoding AgentRcvFileId where strDecode s = AgentRcvFileId <$> strDecode s strP = AgentRcvFileId <$> strP +instance FromJSON AgentRcvFileId where + parseJSON = strParseJSON "AgentRcvFileId" + instance ToJSON AgentRcvFileId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1142,6 +1150,9 @@ instance StrEncoding AgentInvId where strDecode s = AgentInvId <$> strDecode s strP = AgentInvId <$> strP +instance FromJSON AgentInvId where + parseJSON = strParseJSON "AgentInvId" + instance ToJSON AgentInvId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1158,6 +1169,9 @@ data FileTransfer | FTRcv {rcvFileTransfer :: RcvFileTransfer} deriving (Show, Generic) +instance FromJSON FileTransfer where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "FT" + instance ToJSON FileTransfer where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" @@ -1172,7 +1186,7 @@ data FileTransferMeta = FileTransferMeta chunkSize :: Integer, cancelled :: Bool } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON FileTransferMeta where toEncoding = J.genericToEncoding J.defaultOptions @@ -1182,7 +1196,7 @@ data XFTPSndFile = XFTPSndFile agentSndFileDeleted :: Bool, cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON XFTPSndFile where toEncoding = J.genericToEncoding J.defaultOptions @@ -1197,6 +1211,9 @@ instance FromField FileStatus where fromField = fromTextField_ textDecode instance ToField FileStatus where toField = toField . textEncode +instance FromJSON FileStatus where + parseJSON = textParseJSON "FileStatus" + instance ToJSON FileStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1250,11 +1267,9 @@ connDisabled :: Connection -> Bool connDisabled Connection {authErrCounter} = authErrCounter >= authErrDisableCount data SecurityCode = SecurityCode {securityCode :: Text, verifiedAt :: UTCTime} - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) -instance ToJSON SecurityCode where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON SecurityCode where toEncoding = J.genericToEncoding J.defaultOptions verificationCode :: ByteString -> Text verificationCode = T.pack . unwords . chunks 5 . show . os2ip @@ -1273,6 +1288,9 @@ aConnId Connection {agentConnId = AgentConnId cId} = cId connIncognito :: Connection -> Bool connIncognito Connection {customUserProfileId} = isJust customUserProfileId +instance FromJSON Connection where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + instance ToJSON Connection where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -1290,7 +1308,7 @@ data PendingContactConnection = PendingContactConnection createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) aConnId' :: PendingContactConnection -> ConnId aConnId' PendingContactConnection {pccAgentConnId = AgentConnId cId} = cId @@ -1318,6 +1336,9 @@ instance FromField ConnStatus where fromField = fromTextField_ textDecode instance ToField ConnStatus where toField = toField . textEncode +instance FromJSON ConnStatus where + parseJSON = textParseJSON "ConnStatus" + instance ToJSON ConnStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1348,6 +1369,9 @@ instance FromField ConnType where fromField = fromTextField_ textDecode instance ToField ConnType where toField = toField . textEncode +instance FromJSON ConnType where + parseJSON = textParseJSON "ConnType" + instance ToJSON ConnType where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1550,6 +1574,24 @@ instance ToJSON ChatVersionRange where newtype JVersionRange = JVersionRange {fromJVersionRange :: VersionRange} deriving (Eq, Show) +instance FromJSON JVersionRange where + parseJSON = J.withObject "JVersionRange" $ \o -> do + minv <- o .: "minVersion" + maxv <- o .: "maxVersion" + maybe (fail "bad version range") (pure . JVersionRange) $ safeVersionRange minv maxv + instance ToJSON JVersionRange where toJSON (JVersionRange (VersionRange minV maxV)) = J.object ["minVersion" .= minV, "maxVersion" .= maxV] toEncoding (JVersionRange (VersionRange minV maxV)) = J.pairs $ "minVersion" .= minV <> "maxVersion" .= maxV + +$(JQ.deriveJSON defOpts ''UserPwdHash) + +$(JQ.deriveJSON defOpts ''User) + +$(JQ.deriveJSON defOpts ''NewUser) + +$(JQ.deriveJSON defOpts ''UserInfo) + +$(JQ.deriveJSON defOpts ''Contact) + +$(JQ.deriveJSON defOpts ''ContactRef) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index c53e4476f4..c7555e18a8 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -338,7 +338,7 @@ data ContactUserPreferences = ContactUserPreferences voice :: ContactUserPreference VoicePreference, calls :: ContactUserPreference CallsPreference } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) data ContactUserPreference p = ContactUserPreference { enabled :: PrefEnabled, @@ -352,8 +352,13 @@ data ContactUserPref p = CUPContact {preference :: p} | CUPUser {preference :: p instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions +instance FromJSON p => FromJSON (ContactUserPreference p) where parseJSON = J.genericParseJSON J.defaultOptions + instance ToJSON p => ToJSON (ContactUserPreference p) where toEncoding = J.genericToEncoding J.defaultOptions +instance FromJSON p => FromJSON (ContactUserPref p) where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CUP" + instance ToJSON p => ToJSON (ContactUserPref p) where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CUP" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP" diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs index fffdd24b9e..8681e99086 100644 --- a/src/Simplex/Chat/Types/Util.hs +++ b/src/Simplex/Chat/Types/Util.hs @@ -28,3 +28,6 @@ fromBlobField_ p = \case Right k -> Ok k Left e -> returnError ConversionFailed f ("could not parse field: " ++ e) f -> returnError ConversionFailed f "expecting SQLBlob column type" + +defOpts :: J.Options +defOpts = J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 842a84cc65..f32b1835ed 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -73,10 +73,10 @@ responseToView :: Maybe User -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRUsersList users -> viewUsersList users - CRChatStarted -> ["chat started"] - CRChatRunning -> ["chat is running"] - CRChatStopped -> ["chat stopped"] - CRChatSuspended -> ["chat suspended"] + CRChatStarted _ -> ["chat started"] + CRChatRunning _ -> ["chat is running"] + CRChatStopped _ -> ["chat stopped"] + CRChatSuspended _ -> ["chat suspended"] CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [plain . bshow $ J.encode chats] CRChats chats -> viewChats ts tz chats CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [plain . bshow $ J.encode chat] @@ -267,7 +267,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRRemoteHostDeleted rhId -> ["remote host " <> sShow rhId <> " deleted"] CRRemoteCtrlList cs -> viewRemoteCtrls cs CRRemoteCtrlRegistered rcId -> ["remote controller " <> sShow rcId <> " registered"] - CRRemoteCtrlStarted -> ["remote controller started"] + CRRemoteCtrlStarted _ -> ["remote controller started"] CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] CRRemoteCtrlAccepted rcId -> ["remote controller " <> sShow rcId <> " accepted"] diff --git a/stack.yaml b/stack.yaml index bce5dd3a68..9a343bcad2 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: ec1b72cb8013a65a5d9783104a47ae44f5730089 + commit: 96a38505d63ec9a12096991e7725b250e397af72 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher From 34e1e44338e77f038821d4e6a86bc70df7c6ba22 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 6 Oct 2023 04:49:18 +0800 Subject: [PATCH 041/294] android, desktop: profile names (remove full name) (#3177) * desktop, android: profile names (remove full name) * rename back * disallow spaces only in names * ios: disallow spaces only in names * changes --- .../Views/Chat/Group/GroupProfileView.swift | 6 +- .../Shared/Views/NewChat/AddGroupView.swift | 3 +- .../Views/UserSettings/UserProfile.swift | 2 +- .../src/commonMain/cpp/android/simplex-api.c | 9 + .../src/commonMain/cpp/desktop/simplex-api.c | 11 +- .../kotlin/chat/simplex/common/App.kt | 3 +- .../chat/simplex/common/platform/Core.kt | 1 + .../chat/simplex/common/views/WelcomeView.kt | 276 ++++++++++++------ .../views/chat/group/GroupProfileView.kt | 46 +-- .../common/views/newchat/AddGroupView.kt | 31 +- .../common/views/onboarding/OnboardingView.kt | 40 --- .../common/views/usersettings/SettingsView.kt | 2 +- .../views/usersettings/UserProfileView.kt | 64 ++-- .../views/usersettings/UserProfilesView.kt | 3 +- .../commonMain/resources/MR/base/strings.xml | 11 +- 15 files changed, 288 insertions(+), 220 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 6ff3af66a5..7e123c389f 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -80,7 +80,7 @@ struct GroupProfileView: View { HStack(spacing: 20) { Button("Cancel") { dismiss() } Button("Save group profile") { saveProfile() } - .disabled(groupProfile.displayName == "" || !validNewProfileName()) + .disabled(!canUpdateProfile()) } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) @@ -134,6 +134,10 @@ struct GroupProfileView: View { .onTapGesture { hideKeyboard() } } + private func canUpdateProfile() -> Bool { + groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName() + } + private func validNewProfileName() -> Bool { groupProfile.displayName == groupInfo.groupProfile.displayName || validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces)) diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 5cea52cc8b..186a24e995 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -179,7 +179,8 @@ struct AddGroupView: View { } func canCreateProfile() -> Bool { - profile.displayName != "" && validDisplayName(profile.displayName.trimmingCharacters(in: .whitespaces)) + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + return name != "" && validDisplayName(name) } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 88be0b6a7f..b1a362a5a1 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -162,7 +162,7 @@ struct UserProfile: View { } private func canSaveProfile(_ user: User) -> Bool { - profile.displayName != "" && validNewProfileName(user) + profile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName(user) } func saveProfile() { diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index dbfee70257..351ed93c97 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -46,6 +46,7 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); +extern char *chat_valid_name(const char *name); extern char *chat_write_file(const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_encrypt_file(const char *from_path, const char *to_path); @@ -121,6 +122,14 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) { + const char *_name = (*env)->GetStringUTFChars(env, name, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_valid_name(_name)); + (*env)->ReleaseStringUTFChars(env, name, _name); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index ddc5c92f93..f36c86c366 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -21,6 +21,7 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); +extern char *chat_valid_name(const char *name); extern char *chat_write_file(const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_encrypt_file(const char *from_path, const char *to_path); @@ -75,7 +76,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, jclass cla jstring res = decode_to_utf8_string(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); - (*env)->ReleaseStringUTFChars(env, dbKey, _confirm); + (*env)->ReleaseStringUTFChars(env, confirm, _confirm); // Creating array of Object's (boxed values can be passed, eg. Long instead of long) jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); @@ -133,6 +134,14 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass cl return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) { + const char *_name = encode_to_utf8_chars(env, name); + jstring res = decode_to_utf8_string(env, chat_valid_name(_name)); + (*env)->ReleaseStringUTFChars(env, name, _name); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { const char *_path = encode_to_utf8_chars(env, path); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index c08ad5f914..4531f88f96 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -17,6 +17,7 @@ import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.CreateFirstProfile import chat.simplex.common.views.helpers.SimpleButton import chat.simplex.common.views.SplashView import chat.simplex.common.views.call.ActiveCallView @@ -135,7 +136,7 @@ fun MainScreen() { ModalManager.fullscreen.showInView() } } - onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {} + onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 801a0270e2..2bed24b1f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -20,6 +20,7 @@ external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String external fun chatParseMarkdown(str: String): String external fun chatParseServer(str: String): String external fun chatPasswordHash(pwd: String, salt: String): String +external fun chatValidName(name: String): String external fun chatWriteFile(path: String, buffer: ByteBuffer): String external fun chatReadFile(path: String, key: String, nonce: String): Array external fun chatEncryptFile(fromPath: String, toPath: String): String diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 13ce16d0a7..504ecac89d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -1,10 +1,10 @@ package chat.simplex.common.views +import SectionTextFooter import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.material.MaterialTheme.colors import androidx.compose.runtime.* @@ -18,115 +18,160 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel import chat.simplex.common.model.Profile -import chat.simplex.common.platform.appPlatform -import chat.simplex.common.platform.navigationBarsWithImePadding +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.views.onboarding.* +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged - -fun isValidDisplayName(name: String) : Boolean { - return (name.firstOrNull { it.isWhitespace() }) == null && !name.startsWith("@") && !name.startsWith("#") -} +import kotlinx.coroutines.launch @Composable -fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) { - val displayName = rememberSaveable { mutableStateOf("") } - val fullName = rememberSaveable { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } +fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val keyboardState by getKeyboardState() + var savedKeyboardState by remember { mutableStateOf(keyboardState) } - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) - ) { - /*CloseSheetBar(close = { - if (chatModel.users.isEmpty()) { - chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo - } else { - close() - } - })*/ - Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { - AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING) - ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1) - ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1) - Spacer(Modifier.height(DEFAULT_PADDING)) - Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - stringResource(MR.strings.display_name), - fontSize = 16.sp - ) - if (!isValidDisplayName(displayName.value)) { - Text( - stringResource(MR.strings.no_spaces), - fontSize = 16.sp, - color = Color.Red - ) - } - } - ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) - Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.full_name_optional__prompt), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(fullName, "") - } - Spacer(Modifier.fillMaxHeight().weight(1f)) - Row { - if (chatModel.users.isEmpty()) { - SimpleButtonDecorated( - text = stringResource(MR.strings.about_simplex), - icon = painterResource(MR.images.ic_arrow_back_ios_new), - textDecoration = TextDecoration.None, - fontWeight = FontWeight.Medium - ) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } - } - Spacer(Modifier.fillMaxWidth().weight(1f)) - val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) - val createModifier: Modifier - val createColor: Color - if (enabled) { - createModifier = Modifier.clickable { - if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { - createProfileInProfiles(chatModel, displayName.value, fullName.value, close) - } else { - createProfileOnboarding(chatModel, displayName.value, fullName.value, close) + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 20.dp) + ) { + val displayName = rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + ) { + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING) + Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + stringResource(MR.strings.display_name), + fontSize = 16.sp + ) + val name = displayName.value.trim() + val validName = mkValidName(name) + Spacer(Modifier.height(20.dp)) + if (name != validName) { + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester) + } + SettingsActionItem( + painterResource(MR.images.ic_check), + stringResource(MR.strings.create_another_profile_button), + disabled = !canCreateProfile(displayName.value), + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + click = { createProfileInProfiles(chatModel, displayName.value, close) }, + ) + SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device)) + SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts)) + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + } + if (savedKeyboardState != keyboardState) { + LaunchedEffect(keyboardState) { + scope.launch { + savedKeyboardState = keyboardState + scrollState.animateScrollTo(scrollState.maxValue) } - }.padding(8.dp) - createColor = MaterialTheme.colors.primary - } else { - createModifier = Modifier.padding(8.dp) - createColor = MaterialTheme.colors.secondary - } - Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) { - Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium) - Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = createColor) } } - } - - LaunchedEffect(Unit) { - delay(300) - focusRequester.requestFocus() } } } -fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { +@Composable +fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val keyboardState by getKeyboardState() + var savedKeyboardState by remember { mutableStateOf(keyboardState) } + + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 20.dp) + ) { + val displayName = rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + ) { + /*CloseSheetBar(close = { + if (chatModel.users.isEmpty()) { + chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + } else { + close() + } + })*/ + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING) + ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary)) + ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary)) + Spacer(Modifier.height(DEFAULT_PADDING)) + Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + stringResource(MR.strings.display_name), + fontSize = 16.sp + ) + val name = displayName.value.trim() + val validName = mkValidName(name) + Spacer(Modifier.height(20.dp)) + if (name != validName) { + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester) + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + OnboardingButtons(displayName, close) + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + } + LaunchedEffect(Unit) { + setLastVersionDefault(chatModel) + } + if (savedKeyboardState != keyboardState) { + LaunchedEffect(keyboardState) { + scope.launch { + savedKeyboardState = keyboardState + scrollState.animateScrollTo(scrollState.maxValue) + } + } + } + } + } +} + +fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { val user = chatModel.controller.apiCreateActiveUser( - Profile(displayName, fullName, null) + Profile(displayName.trim(), "", null) ) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { @@ -142,10 +187,10 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: } } -fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { +fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { chatModel.controller.apiCreateActiveUser( - Profile(displayName, fullName, null) + Profile(displayName.trim(), "", null) ) ?: return@withApi val onboardingStage = chatModel.controller.appPrefs.onboardingStage if (chatModel.users.isEmpty()) { @@ -163,6 +208,28 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: } } +@Composable +fun OnboardingButtons(displayName: MutableState, close: () -> Unit) { + Row { + SimpleButtonDecorated( + text = stringResource(MR.strings.about_simplex), + icon = painterResource(MR.images.ic_arrow_back_ios_new), + textDecoration = TextDecoration.None, + fontWeight = FontWeight.Medium + ) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } + Spacer(Modifier.fillMaxWidth().weight(1f)) + val enabled = canCreateProfile(displayName.value) + val createModifier: Modifier = Modifier.clickable(enabled) { createProfileOnboarding(chatModel, displayName.value, close) }.padding(8.dp) + val createColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) { + Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium) + Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = createColor) + } + } + } +} + @Composable fun ProfileNameField(name: MutableState, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) { var valid by rememberSaveable { mutableStateOf(true) } @@ -195,10 +262,6 @@ fun ProfileNameField(name: MutableState, placeholder: String = "", isVal onValueChange = { name.value = it }, modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester), textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), singleLine = true, cursorBrush = SolidColor(MaterialTheme.colors.secondary) ) @@ -211,3 +274,28 @@ fun ProfileNameField(name: MutableState, placeholder: String = "", isVal } } } + +private fun canCreateProfile(displayName: String): Boolean { + val name = displayName.trim() + return name.isNotEmpty() && mkValidName(name) == name +} + +fun showInvalidNameAlert(name: String, displayName: MutableState) { + if (name.isEmpty()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_name), + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.invalid_name), + text = generalGetString(MR.strings.correct_name_to).format(name), + onConfirm = { + displayName.value = name + } + ) + } +} + +fun isValidDisplayName(name: String) : Boolean = mkValidName(name.trim()) == name + +fun mkValidName(s: String): String = chatValidName(s) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index ac13dd483a..5376cb092b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -19,12 +19,12 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.ProfileNameField +import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.isValidDisplayName import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.net.URI @@ -65,13 +65,13 @@ fun GroupProfileLayout( fullName.value == groupProfile.fullName && groupProfile.image == profileImage.value val closeWithAlert = { - if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) { + if (dataUnchanged || !canUpdateProfile(displayName.value, groupProfile)) { close() } else { showUnsavedChangesAlert({ saveProfile( groupProfile.copy( - displayName = displayName.value, + displayName = displayName.value.trim(), fullName = fullName.value, image = profileImage.value ) @@ -125,32 +125,32 @@ fun GroupProfileLayout( stringResource(MR.strings.group_display_name_field), fontSize = 16.sp ) - if (!isValidDisplayName(displayName.value)) { + if (!isValidNewProfileName(displayName.value, groupProfile)) { Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - Text( - stringResource(MR.strings.no_spaces), - fontSize = 16.sp, - color = Color.Red - ) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } } } - ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) + ProfileNameField(displayName, "", { isValidNewProfileName(it, groupProfile) }, focusRequester) + if (groupProfile.fullName.isNotEmpty() && groupProfile.fullName != groupProfile.displayName) { + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.group_full_name_field), + fontSize = 16.sp, + modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) + ) + ProfileNameField(fullName) + } Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.group_full_name_field), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(fullName) - Spacer(Modifier.height(DEFAULT_PADDING)) - val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) + val enabled = !dataUnchanged && canUpdateProfile(displayName.value, groupProfile) if (enabled) { Text( stringResource(MR.strings.save_group_profile), modifier = Modifier.clickable { saveProfile( groupProfile.copy( - displayName = displayName.value, + displayName = displayName.value.trim(), fullName = fullName.value, image = profileImage.value ) @@ -178,6 +178,12 @@ fun GroupProfileLayout( } } +private fun canUpdateProfile(displayName: String, groupProfile: GroupProfile): Boolean = + displayName.trim().isNotEmpty() && isValidNewProfileName(displayName, groupProfile) + +private fun isValidNewProfileName(displayName: String, groupProfile: GroupProfile): Boolean = + displayName == groupProfile.displayName || isValidDisplayName(displayName.trim()) + private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.save_preferences_question), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 3ab5ef9b2b..6ad919c27d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -19,15 +19,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.ProfileNameField import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.isValidDisplayName import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.DeleteImageButton import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.platform.* +import chat.simplex.common.views.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -60,7 +59,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val displayName = rememberSaveable { mutableStateOf("") } - val fullName = rememberSaveable { mutableStateOf("") } val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } @@ -110,31 +108,22 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { stringResource(MR.strings.group_display_name_field), fontSize = 16.sp ) - if (!isValidDisplayName(displayName.value)) { + if (!isValidDisplayName(displayName.value.trim())) { Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - Text( - stringResource(MR.strings.no_spaces), - fontSize = 16.sp, - color = Color.Red - ) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value.trim()), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } } } - ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) - Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.group_full_name_field), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(fullName, "") + ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) Spacer(Modifier.height(8.dp)) - val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) + val enabled = canCreateProfile(displayName.value) if (enabled) { CreateGroupButton(MaterialTheme.colors.primary, Modifier .clickable { createGroup(GroupProfile( - displayName = displayName.value, - fullName = fullName.value, + displayName = displayName.value.trim(), + fullName = "", image = profileImage.value )) } @@ -167,6 +156,8 @@ fun CreateGroupButton(color: Color, modifier: Modifier) { } } +fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmpty() && isValidDisplayName(displayName.trim()) + @Preview @Composable fun PreviewAddGroupLayout() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index 119ed8cd48..0a48bbd1eb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -1,16 +1,5 @@ package chat.simplex.common.views.onboarding -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.platform.ProvideWindowInsets -import chat.simplex.common.views.CreateProfilePanel -import chat.simplex.common.platform.getKeyboardState -import kotlinx.coroutines.launch - enum class OnboardingStage { Step1_SimpleXInfo, Step2_CreateProfile, @@ -19,32 +8,3 @@ enum class OnboardingStage { Step4_SetNotificationsMode, OnboardingComplete } - -@Composable -fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - val keyboardState by getKeyboardState() - var savedKeyboardState by remember { mutableStateOf(keyboardState) } - - ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = 20.dp) - ) { - CreateProfilePanel(chatModel, close) - LaunchedEffect(Unit) { - setLastVersionDefault(chatModel) - } - if (savedKeyboardState != keyboardState) { - LaunchedEffect(keyboardState) { - scope.launch { - savedKeyboardState = keyboardState - scrollState.animateScrollTo(scrollState.maxValue) - } - } - } - } - } -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index eebe7b3f49..d949f800bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -364,7 +364,7 @@ fun AppVersionItem(showVersion: () -> Unit) { maxLines = 1, overflow = TextOverflow.Ellipsis ) - if (profileOf.fullName.isNotEmpty()) { + if (profileOf.fullName.isNotEmpty() && profileOf.fullName != profileOf.displayName) { Text( profileOf.fullName, Modifier.padding(vertical = 5.dp), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index a7ae3a6751..38c9e58ad2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -17,14 +17,12 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.ProfileNameField import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.isValidDisplayName import chat.simplex.common.views.onboarding.ReadableText -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.Profile import chat.simplex.common.platform.* +import chat.simplex.common.views.* import chat.simplex.res.MR import kotlinx.coroutines.launch import java.net.URI @@ -39,7 +37,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { close, saveProfile = { displayName, fullName, image -> withApi { - val updated = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName, fullName = fullName, image = image)) + val updated = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName.trim(), fullName = fullName, image = image)) if (updated != null) { val (newProfile, _) = updated chatModel.updateCurrentUser(newProfile) @@ -89,7 +87,7 @@ fun UserProfileLayout( profile.image == profileImage.value val closeWithAlert = { - if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) { + if (dataUnchanged || !canSaveProfile(displayName.value, profile)) { close() } else { showUnsavedChangesAlert({ saveProfile(displayName.value, fullName.value, profileImage.value) }, close) @@ -128,36 +126,27 @@ fun UserProfileLayout( stringResource(MR.strings.display_name__field), fontSize = 16.sp ) - if (!isValidDisplayName(displayName.value)) { - Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - Text( - stringResource(MR.strings.no_spaces), - fontSize = 16.sp, - color = Color.Red - ) + if (!isValidNewProfileName(displayName.value, profile)) { + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } } } - ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) - Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.full_name__field), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(fullName) - - Spacer(Modifier.height(DEFAULT_PADDING)) - val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) - val saveModifier: Modifier - val saveColor: Color - if (enabled) { - saveModifier = Modifier - .clickable { saveProfile(displayName.value, fullName.value, profileImage.value) } - saveColor = MaterialTheme.colors.primary - } else { - saveModifier = Modifier - saveColor = MaterialTheme.colors.secondary + ProfileNameField(displayName, "", { isValidNewProfileName(it, profile) }, focusRequester) + if (showFullName(profile)) { + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.full_name__field), + fontSize = 16.sp, + modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) + ) + ProfileNameField(fullName) } + Spacer(Modifier.height(DEFAULT_PADDING)) + val enabled = !dataUnchanged && canSaveProfile(displayName.value, profile) + val saveModifier: Modifier = Modifier.clickable(enabled) { saveProfile(displayName.value, fullName.value, profileImage.value) } + val saveColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Text( stringResource(MR.strings.save_and_notify_contacts), modifier = saveModifier, @@ -216,6 +205,15 @@ private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { ) } +private fun isValidNewProfileName(displayName: String, profile: Profile): Boolean = + displayName == profile.displayName || isValidDisplayName(displayName.trim()) + +private fun showFullName(profile: Profile): Boolean = + profile.fullName.isNotEmpty() && profile.fullName != profile.displayName + +private fun canSaveProfile(displayName: String, profile: Profile): Boolean = + displayName.trim().isNotEmpty() && isValidNewProfileName(displayName, profile) + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index 7929413c93..7d3239700f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -28,9 +28,8 @@ import chat.simplex.common.views.chatlist.UserProfilePickerItem import chat.simplex.common.views.chatlist.UserProfileRow import chat.simplex.common.views.database.PassphraseField import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.CreateProfile -import chat.simplex.common.model.* import chat.simplex.common.platform.appPlatform +import chat.simplex.common.views.CreateProfile import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index d57211f4ac..7912a5ad1b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -675,7 +675,7 @@ Your contacts in SimpleX will see it.\nYou can change it in Settings. - Display name: + Profile name: Full name: Your current profile Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. @@ -703,11 +703,12 @@ Create profile Your profile, contacts and delivered messages are stored on your device. The profile is only shared with your contacts. - No spaces! Display name cannot contain whitespace. - Display Name - Full Name (optional) + Enter your name: Create + Create profile + Invalid name! + Correct name to %s? About SimpleX @@ -1290,7 +1291,7 @@ Create secret group The group is fully decentralized – it is visible only to the members. - Group display name: + Enter group name: Group full name: Your chat profile will be sent to group members From a273c6859697f70c798881db825e766282c83a85 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 5 Oct 2023 22:33:48 +0100 Subject: [PATCH 042/294] core: rename migration, pin dependencies --- package.yaml | 4 +-- simplex-chat.cabal | 30 +++++++++---------- ...ller.hs => M20231005_remote_controller.hs} | 10 +++---- src/Simplex/Chat/Store/Migrations.hs | 6 ++-- 4 files changed, 25 insertions(+), 25 deletions(-) rename src/Simplex/Chat/Migrations/{M20230922_remote_controller.hs => M20231005_remote_controller.hs} (84%) diff --git a/package.yaml b/package.yaml index 663ae5bc26..f7fc614789 100644 --- a/package.yaml +++ b/package.yaml @@ -32,7 +32,7 @@ dependencies: - exceptions == 0.10.* - filepath == 1.4.* - http-types == 0.12.* - - http2 + - http2 == 4.1.* - memory == 0.18.* - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 @@ -50,7 +50,7 @@ dependencies: - terminal == 0.2.* - text == 2.0.* - time == 1.9.* - - tls + - tls >= 1.6.0 && < 1.7 - unliftio == 0.2.* - unliftio-core == 0.2.* - zip == 2.0.* diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8963203c11..8fa33f07f6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -113,8 +113,8 @@ library Simplex.Chat.Migrations.M20230903_connections_to_subscribe Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes - Simplex.Chat.Migrations.M20230922_remote_controller Simplex.Chat.Migrations.M20230926_contact_status + Simplex.Chat.Migrations.M20231005_remote_controller Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -170,7 +170,7 @@ library , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -188,7 +188,7 @@ library , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -223,7 +223,7 @@ executable simplex-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -242,7 +242,7 @@ executable simplex-bot , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -277,7 +277,7 @@ executable simplex-bot-advanced , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -296,7 +296,7 @@ executable simplex-bot-advanced , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -333,7 +333,7 @@ executable simplex-broadcast-bot , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -352,7 +352,7 @@ executable simplex-broadcast-bot , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -388,7 +388,7 @@ executable simplex-chat , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* @@ -407,7 +407,7 @@ executable simplex-chat , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , websockets ==0.12.* @@ -447,7 +447,7 @@ executable simplex-directory-service , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 @@ -466,7 +466,7 @@ executable simplex-directory-service , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -529,7 +529,7 @@ test-suite simplex-chat-test , filepath ==1.4.* , hspec ==2.11.* , http-types ==0.12.* - , http2 + , http2 ==4.1.* , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* @@ -549,7 +549,7 @@ test-suite simplex-chat-test , terminal ==0.2.* , text ==2.0.* , time ==1.9.* - , tls + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* diff --git a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs b/src/Simplex/Chat/Migrations/M20231005_remote_controller.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20230922_remote_controller.hs rename to src/Simplex/Chat/Migrations/M20231005_remote_controller.hs index 21d653d124..0cb8634999 100644 --- a/src/Simplex/Chat/Migrations/M20230922_remote_controller.hs +++ b/src/Simplex/Chat/Migrations/M20231005_remote_controller.hs @@ -1,12 +1,12 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230922_remote_controller where +module Simplex.Chat.Migrations.M20231005_remote_controller where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20230922_remote_controller :: Query -m20230922_remote_controller = +m20231005_remote_controller :: Query +m20231005_remote_controller = [sql| CREATE TABLE remote_hosts ( -- hosts known to a controlling app remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -25,8 +25,8 @@ CREATE TABLE remote_controllers ( -- controllers known to a hosting app ); |] -down_m20230922_remote_controller :: Query -down_m20230922_remote_controller = +down_m20231005_remote_controller :: Query +down_m20231005_remote_controller = [sql| DROP TABLE remote_hosts; DROP TABLE remote_controllers; diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index c694a53714..5d789003a5 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -81,8 +81,8 @@ import Simplex.Chat.Migrations.M20230829_connections_chat_vrange import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes -import Simplex.Chat.Migrations.M20230922_remote_controller import Simplex.Chat.Migrations.M20230926_contact_status +import Simplex.Chat.Migrations.M20231005_remote_controller import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -164,8 +164,8 @@ schemaMigrations = ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), - ("20230922_remote_controller", m20230922_remote_controller, Just down_m20230922_remote_controller), - ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status) + ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), + ("20231005_remote_controller", m20231005_remote_controller, Just down_m20231005_remote_controller) ] -- | The list of migrations in ascending order by date From 1be70169bab98d135e9618cac4771663ef20b53a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:27:13 +0400 Subject: [PATCH 043/294] docs: contact merge issues rfc (#3179) --- .../2023-10-05-contact-merge-improvement.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/rfcs/2023-10-05-contact-merge-improvement.md diff --git a/docs/rfcs/2023-10-05-contact-merge-improvement.md b/docs/rfcs/2023-10-05-contact-merge-improvement.md new file mode 100644 index 0000000000..e0588998ec --- /dev/null +++ b/docs/rfcs/2023-10-05-contact-merge-improvement.md @@ -0,0 +1,92 @@ +# Contact merge issues, eradication, improvement + +## Problem + +Contact merge (see mergeContactRecords) keeps connections of both contacts by assigning both connections to the merged contact. + +When contact is read, last ready connection is selected as contact's active connection (see, for example, getContact - connections are ordered by connection_id in descending order). This may lead to contacts reading different connections, and as a result message delivery failing. Consider following scenarios: +- connection IDs may be in different order for each side, if one of sides inserted connection in gap of IDs after an older (different) connection was deleted. If contacts consider different connections as active for each other, they would subscribe to and send to different connections, completely destabilizing delivery. +- XInfoProbeOk may never be delivered due to server error or processed due to client error. In this case probe sender has 2 separate contacts and user may still use old contact for sending; other side now has a single merged contact record with the latest connection as active - if they restart or resubscribe they would stop receiving messages from the old connection. Perhaps it's less of an issue because that second side would at least send to a new connection which first side (probe sender) considers active for a separate contact, and would receive messages to that new contact. Also if they're establishing a second contact-pair, they may be more likely to use this new contact-pair anyway. + +## Solution ideas + +### Don't merge contacts + +Still merge members and contacts. + +Pros: +- no risk of destabilizing existing conversation. +- eventually (paired with a migration for legacy merged contacts) contact reading queries may be simplified to read a single connection, as well several chat logic flows accounting for multiple contact connections. +- direct connection not being replaced with a new connection established via group (in case of "send direct message" feature, or pre 5.3 in case of direct connections established in group). +- connection verification status not being reset (same + duplicate direct connections). +- feature of having separate parallel conversations with same contact is kept. +- easy to implement: + - in probeMatchingContactsAndMembers only match members (getMatchingMembers), don't match contacts (remove getMatchingContacts). + - in probeMatch prohibit merging contacts (cgm1 is COMContact, cgm2 is COMContact case; also member with associated contact case). + - in xInfoProbeOk - same. + +Cons: +- "split identity" of contacts - though in many cases it's status quo without latest change (https://github.com/simplex-chat/simplex-chat/pull/3173). It may be not a big enough issue to justify risking connection stability. +- members may be matched to arbitrary contacts - need to consider consequences more thoroughly. Though at least they should match to same contact-pair on both sides. "Send direct message" member contacts will not be matched to existing contacts, though going forward with contact-member merge working it shouldn't be an issue until contact is deleted on one of sides. +- group link host not being merged - member knowing host as a contact would have him as a separate contact now. Perhaps we could rework group link protocol to avoid contact creation - new connection would already be created for host-invitee group connection, with some new connection entity "pending group member" created on both sides (starting with next protocol version support on both sides). This member would then be merged to the existing contact. This would be the most expensive part of this change, if we choose not to ignore it. + +### Improve contact merge protocol + +- instead of relying on ordering when selecting active contact connection, add flag active_contact_conn, set it on mergeContactRecords: + + ~~verified connection >~~ direct connection > newer connection + + we can't set to verified connection because it's not necessarily symmetric + + ~~direct connection >~~ newer connection + + for backwards compatibility we can't set to direct - other side may still set to newer. Unless we bump protocol version and choose based on that. + + choose "newer" connection based on created_at instead of connection_id - it should be more likely to have the same order. + +- initial probe sender to send new message XInfoProbeComplete after processing XInfoProbeOk to signal that old connection is good to delete. + +- probe receiver to keep subscribing to both connections until they receive XInfoProbeComplete. + +- negotiate connection to use - shared connection id? + +``` + Alice Bob + 2 contacts | | 2 contacts + 2 connections | | 2 connections + | XInfoProbe | + |------------------------>| + | XInfoProbeCheck | + |------------------------>| + | | match probe and hash; + | | merge contacts + | | 1 contact + | | 2 connections + | | (1 active, subscribe to both) + | XInfoProbeOk | + |<------------------------| + merge contacts | | + 1 contact | | + 1 connection | | + (delete non active) | | + | XInfoProbeComplete | + |------------------------>| + | | 1 contact + | | 1 connection + | | (delete non active) + * * +``` + +Pros: +- contact merge is already implemented. +- maintains contact "identity". +- members are matched to a single contact. +- no need to rework group links. + +Cons: +- more complex logic - more prone to error. +- likely still risks destabilizing connection. +- continue to maintain and add new chat logic flows accounting for multiple connections (for instance, subscription). +- newer connection replace verified and/or direct connections. These new connections are still possible to establish via indirect and unverified group connection ("send direct message"). +- removes "parallel conversations" feature. It's not a hard loss though. +- existing contacts with aliases still not to be merged - to be implemented. Not a con, but a special case for contact merge. From b05a45f55976e0466c71e651b21fe294b2941eb3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 7 Oct 2023 12:32:39 +0100 Subject: [PATCH 044/294] core: updated simplexmq (add tag to platform-specific sum type encoding that uses single field objects with swift/iOS) --- cabal.project | 4 ++-- scripts/nix/sha256map.nix | 4 ++-- stack.yaml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cabal.project b/cabal.project index af664652db..3da442ac49 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: ec1b72cb8013a65a5d9783104a47ae44f5730089 + tag: 919550948501d315aa8845cbed1781d4298d4ced source-repository-package type: git @@ -34,7 +34,7 @@ source-repository-package source-repository-package type: git location: https://github.com/simplex-chat/aeson.git - tag: 68330dce8208173c6acf5f62b23acb500ab5d873 + tag: aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index b6ca36e313..0a199779d0 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,10 +1,10 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ec1b72cb8013a65a5d9783104a47ae44f5730089" = "1lz5rvgxp242zg95r9zd9j50y45314cf8nfpjg1qsa55nrk2w19b"; + "https://github.com/simplex-chat/simplexmq.git"."919550948501d315aa8845cbed1781d4298d4ced" = "05d0cadhlazqi2lxcb7nvyjrf8q49c6ax7b8rahawbh1zmwg38nm"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; - "https://github.com/simplex-chat/aeson.git"."68330dce8208173c6acf5f62b23acb500ab5d873" = "1l51p1v54c88c1jmxcvbz4gy0cns7l46ihzzfjwxxrvcrrrxgcjp"; + "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/simplex-chat/android-support.git"."9aa09f148089d6752ce563b14c2df1895718d806" = "0pbf2pf13v2kjzi397nr13f1h3jv0imvsq8rpiyy2qyx5vd50pqn"; "https://github.com/simplex-chat/network-transport.git"."0013798272a683e35ca38d2fdaf480942311fba8" = "0dnn62apgvc248df0m8ib7phrzn63wm0xs71xvlypv52j6cgwzkb"; diff --git a/stack.yaml b/stack.yaml index bce5dd3a68..5d9fc214fb 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: ec1b72cb8013a65a5d9783104a47ae44f5730089 + commit: 919550948501d315aa8845cbed1781d4298d4ced - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher @@ -60,7 +60,7 @@ extra-deps: commit: a46bd361a19376c5211f1058908fc0ae6bf42446 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson - commit: 68330dce8208173c6acf5f62b23acb500ab5d873 + commit: aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b - github: simplex-chat/haskell-terminal commit: f708b00009b54890172068f168bf98508ffcd495 - github: simplex-chat/android-support From 91561da351c19ce621f0723620519999f162f6b3 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Sat, 7 Oct 2023 16:23:24 +0300 Subject: [PATCH 045/294] core: http transport for remote session (#3178) * Wire some of the session endpoints * Start sending remote commands * Expand remote controller - Fix queues for pumping to remote - Add 3-way test - WIP: Add TTY wrapper for remote hosts - Stop remote controller w/o ids to match starting * Fix view events * Drop notifications, add message test * refactor, receive test * hunt down stray asyncs * Take discovery sockets in brackets --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/simplex-chat/Main.hs | 7 +- .../src/Directory/Service.hs | 2 +- src/Simplex/Chat.hs | 17 +- src/Simplex/Chat/Controller.hs | 95 +++++++- src/Simplex/Chat/Messages.hs | 4 +- src/Simplex/Chat/Remote.hs | 219 ++++++++++++------ src/Simplex/Chat/Remote/Discovery.hs | 26 ++- src/Simplex/Chat/Remote/Types.hs | 24 -- src/Simplex/Chat/Terminal/Input.hs | 10 +- src/Simplex/Chat/Terminal/Output.hs | 18 +- src/Simplex/Chat/Types.hs | 1 + src/Simplex/Chat/View.hs | 22 +- tests/ChatTests/Utils.hs | 3 + tests/RemoteTests.hs | 100 ++++++-- 14 files changed, 376 insertions(+), 172 deletions(-) diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 8dd02623e2..c2ad7e7eb6 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -3,10 +3,11 @@ module Main where import Control.Concurrent (threadDelay) +import Control.Concurrent.STM.TVar (readTVarIO) import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Server -import Simplex.Chat.Controller (versionNumber, versionString) +import Simplex.Chat.Controller (currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Terminal @@ -28,10 +29,12 @@ main = do t <- withTerminal pure simplexChatTerminal terminalChatConfig opts t else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do + rh <- readTVarIO $ currentRemoteHost cc + let cmdRH = rh -- response RemoteHost is the same as for the command itself r <- sendChatCmdStr cc chatCmd ts <- getCurrentTime tz <- getCurrentTimeZone - putStrLn $ serializeChatResponse (Just user) ts tz r + putStrLn $ serializeChatResponse (rh, Just user) ts tz cmdRH r threadDelay $ chatCmdDelay opts * 1000000 welcome :: ChatOpts -> IO () diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 7ed39847a0..a30638249f 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -494,7 +494,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { sendChatCmdStr cc cmdStr >>= \r -> do ts <- getCurrentTime tz <- getCurrentTimeZone - sendReply $ serializeChatResponse (Just user) ts tz r + sendReply $ serializeChatResponse (Nothing, Just user) ts tz Nothing r DCCommandError tag -> sendReply $ "Command error: " <> show tag | otherwise = sendReply "You are not allowed to use this command" where diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bcd23f44ef..dc06d082b8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -193,6 +193,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen firstTime = dbNew chatStore activeTo <- newTVarIO ActiveNone currentUser <- newTVarIO user + currentRemoteHost <- newTVarIO Nothing servers <- agentServers config smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore agentAsync <- newTVarIO Nothing @@ -216,7 +217,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, remoteHostSessions, remoteCtrlSession, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + pure ChatController {activeTo, firstTime, currentUser, currentRemoteHost, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, remoteHostSessions, remoteCtrlSession, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} where configServers :: DefaultAgentServers configServers = @@ -327,7 +328,9 @@ restoreCalls = do atomically $ writeTVar calls callsMap stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do + readTVarIO remoteHostSessions >>= mapM_ cancelRemoteHostSession + readTVarIO remoteCtrlSession >>= mapM_ cancelRemoteCtrlSession_ disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles @@ -349,8 +352,8 @@ execChatCommand rh s = do case parseChatCommand s of Left e -> pure $ chatCmdError u e Right cmd -> case rh of - Nothing -> execChatCommand_ u cmd - Just remoteHostId -> execRemoteCommand u remoteHostId (s, cmd) + Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId (s, cmd) + _ -> execChatCommand_ u cmd execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) @@ -1843,10 +1846,10 @@ processChatCommand = \case StartRemoteHost rh -> startRemoteHost rh StopRemoteHost rh -> closeRemoteHostSession rh DeleteRemoteHost rh -> deleteRemoteHost rh - StartRemoteCtrl -> startRemoteCtrl + StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) AcceptRemoteCtrl rc -> acceptRemoteCtrl rc RejectRemoteCtrl rc -> rejectRemoteCtrl rc - StopRemoteCtrl rc -> stopRemoteCtrl rc + StopRemoteCtrl -> stopRemoteCtrl RegisterRemoteCtrl oob -> registerRemoteCtrl oob ListRemoteCtrls -> listRemoteCtrls DeleteRemoteCtrl rc -> deleteRemoteCtrl rc @@ -5631,7 +5634,7 @@ chatCommandP = "/list remote ctrls" $> ListRemoteCtrls, "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), - "/stop remote ctrl " *> (StopRemoteCtrl <$> A.decimal), + "/stop remote ctrl" $> StopRemoteCtrl, "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 986eaf073e..dad5138bc1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -72,6 +72,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), Cor import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import System.IO (Handle) @@ -171,6 +172,7 @@ data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLite data ChatController = ChatController { currentUser :: TVar (Maybe User), + currentRemoteHost :: TVar (Maybe RemoteHostId), activeTo :: TVar ActiveTo, firstTime :: Bool, smpAgent :: AgentClient, @@ -424,6 +426,7 @@ data ChatCommand | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost RemoteHostId -- ^ Start and announce a remote host + -- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host | StopRemoteHost RemoteHostId -- ^ Shut down a running session | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake @@ -431,7 +434,7 @@ data ChatCommand | ListRemoteCtrls | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data - | StopRemoteCtrl RemoteCtrlId -- ^ Stop listening for announcements or terminate an active session + | StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session | QuitChat | ShowVersion @@ -442,6 +445,29 @@ data ChatCommand | GetAgentSubsDetails deriving (Show) +allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/ForceLocal +allowRemoteCommand = \case + StartChat {} -> False + APIStopChat -> False + APIActivateChat -> False + APISuspendChat {} -> False + SetTempFolder {} -> False + QuitChat -> False + CreateRemoteHost -> False + ListRemoteHosts -> False + StartRemoteHost {} -> False + -- SwitchRemoteHost {} -> False + StopRemoteHost {} -> False + DeleteRemoteHost {} -> False + RegisterRemoteCtrl {} -> False + StartRemoteCtrl -> False + ListRemoteCtrls -> False + AcceptRemoteCtrl {} -> False + RejectRemoteCtrl {} -> False + StopRemoteCtrl -> False + DeleteRemoteCtrl {} -> False + _ -> True + data ChatResponse = CRActiveUser {user :: User} | CRUsersList {users :: [UserInfo]} @@ -619,7 +645,7 @@ data ChatResponse | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlConnecting {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} - | CRRemoteCtrlStopped {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlStopped {_nullary :: Maybe Int} | CRRemoteCtrlDeleted {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} @@ -638,6 +664,27 @@ data ChatResponse | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show) +allowRemoteEvent :: ChatResponse -> Bool +allowRemoteEvent = \case + CRRemoteHostCreated {} -> False + CRRemoteHostList {} -> False + CRRemoteHostStarted {} -> False + CRRemoteHostConnected {} -> False + CRRemoteHostStopped {} -> False + CRRemoteHostDeleted {} -> False + CRRemoteCtrlList {} -> False + CRRemoteCtrlRegistered {} -> False + CRRemoteCtrlStarted {} -> False + CRRemoteCtrlAnnounce {} -> False + CRRemoteCtrlFound {} -> False + CRRemoteCtrlAccepted {} -> False + CRRemoteCtrlRejected {} -> False + CRRemoteCtrlConnecting {} -> False + CRRemoteCtrlConnected {} -> False + CRRemoteCtrlStopped {} -> False + CRRemoteCtrlDeleted {} -> False + _ -> True + logResponseToFile :: ChatResponse -> Bool logResponseToFile = \case CRContactsDisconnected {} -> True @@ -1107,6 +1154,27 @@ instance ToJSON ArchiveError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" +data RemoteHostSession + = RemoteHostSessionStarting + { announcer :: Async () + } + | RemoteHostSessionStarted + { -- | Path for local resources to be synchronized with host + storePath :: FilePath, + ctrlClient :: HTTP2Client + } + +data RemoteCtrlSession = RemoteCtrlSession + { -- | Server side of transport to process remote commands and forward notifications + discoverer :: Async (), + supervisor :: Async (), + hostServer :: Maybe (Async ()), + discovered :: TMap C.KeyHash TransportHost, + accepted :: TMVar RemoteCtrlId, + remoteOutputQ :: TBQueue ChatResponse, + remoteNotifyQ :: TBQueue Notification + } + type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) @@ -1152,16 +1220,19 @@ unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset) -- | Emit local events. toView :: ChatMonad' m => ChatResponse -> m () -toView = toView_ Nothing - --- | Used by transport to mark remote events with source. -toViewRemote :: ChatMonad' m => RemoteHostId -> ChatResponse -> m () -toViewRemote = toView_ . Just - -toView_ :: ChatMonad' m => Maybe RemoteHostId -> ChatResponse -> m () -toView_ rh event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, rh, event) +toView event = do + localQ <- asks outputQ + chatReadVar remoteCtrlSession >>= \case + Nothing -> atomically $ writeTBQueue localQ (Nothing, Nothing, event) + Just RemoteCtrlSession {remoteOutputQ} -> + if allowRemoteEvent event + then do + -- TODO: filter events or let the UI ignore trigger events by itself? + -- traceM $ "Sending event to remote Q: " <> show event + atomically $ writeTBQueue remoteOutputQ event -- TODO: check full? + else do + -- traceM $ "Sending event to local Q: " <> show event + atomically $ writeTBQueue localQ (Nothing, Nothing, event) withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a withStore' action = withStore $ liftIO . action diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b9ce953731..87bd8f4ef0 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -43,7 +43,7 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptSta import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, parseAll, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) @@ -880,7 +880,7 @@ data SndCIStatusProgress deriving (Eq, Show, Generic) instance FromJSON SndCIStatusProgress where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SSP" + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "SSP" instance ToJSON SndCIStatusProgress where toJSON = J.genericToJSON . enumJSON $ dropPrefix "SSP" diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 8f7a3b4f4c..7222743350 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -4,12 +4,15 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} module Simplex.Chat.Remote where +import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Control.Monad.Reader (asks) import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J @@ -18,9 +21,13 @@ import Data.ByteString (ByteString) import qualified Data.ByteString.Base64.URL as B64U import qualified Data.ByteString.Char8 as B import Data.List.NonEmpty (NonEmpty (..)) +import Data.Maybe (fromMaybe) import qualified Data.Map.Strict as M +import qualified Data.Text as T import qualified Network.HTTP.Types as HTTP +import qualified Network.HTTP.Types.Status as Status import qualified Network.HTTP2.Client as HTTP2Client +import qualified Network.HTTP2.Server as HTTP2Server import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller import qualified Simplex.Chat.Remote.Discovery as Discovery @@ -36,7 +43,7 @@ import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 -import Simplex.Messaging.Util (bshow) +import Simplex.Messaging.Util (bshow, ifM, tshow) import System.Directory (getFileSize) import UnliftIO @@ -54,32 +61,67 @@ withRemoteHost remoteHostId action = startRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse startRemoteHost remoteHostId = do - M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case + asks remoteHostSessions >>= atomically . TM.lookup remoteHostId >>= \case Just _ -> throwError $ ChatErrorRemoteHost remoteHostId RHBusy - Nothing -> withRemoteHost remoteHostId run - where - run RemoteHost {storePath, caKey, caCert} = do - announcer <- async $ do - cleanup <- toIO $ closeRemoteHostSession remoteHostId >>= toView - let parent = (C.signatureKeyPair caKey, caCert) - sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" - let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] - Discovery.announceRevHTTP2 cleanup fingerprint credentials >>= \case - Left todo'err -> liftIO cleanup -- TODO: log error - Right ctrlClient -> do - chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarted {storePath, ctrlClient} - -- TODO: start streaming outputQ - toView CRRemoteHostConnected {remoteHostId} + Nothing -> withRemoteHost remoteHostId $ \rh -> do + announcer <- async $ run rh chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarting {announcer} pure CRRemoteHostStarted {remoteHostId} + where + cleanup finished = do + logInfo "Remote host http2 client fininshed" + atomically $ writeTVar finished True + closeRemoteHostSession remoteHostId >>= toView + run RemoteHost {storePath, caKey, caCert} = do + finished <- newTVarIO False + let parent = (C.signatureKeyPair caKey, caCert) + sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" + let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] + Discovery.announceRevHTTP2 (cleanup finished) fingerprint credentials >>= \case + Left h2ce -> do + logError $ "Failed to set up remote host connection: " <> tshow h2ce + cleanup finished + Right ctrlClient -> do + chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarted {storePath, ctrlClient} + chatWriteVar currentRemoteHost $ Just remoteHostId + sendHello ctrlClient >>= \case + Left h2ce -> do + logError $ "Failed to send initial remote host request: " <> tshow h2ce + cleanup finished + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do + logDebug $ "Got initial from remote host: " <> tshow bodyHead + _ <- asks outputQ >>= async . pollRemote finished ctrlClient "/recv" (Nothing, Just remoteHostId,) + toView CRRemoteHostConnected {remoteHostId} + +sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response) +sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing) + where + req = HTTP2Client.requestNoBody "GET" "/" mempty + +pollRemote :: (ChatMonad m, J.FromJSON a) => TVar Bool -> HTTP2Client -> ByteString -> (a -> b) -> TBQueue b -> m () +pollRemote finished http path f queue = loop + where + loop = do + liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case + Left e -> logError $ "pollRemote: " <> tshow (path, e) + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> + case J.eitherDecodeStrict' bodyHead of + Left e -> logError $ "pollRemote/decode: " <> tshow (path, e) + Right o -> atomically $ writeTBQueue queue (f o) + readTVarIO finished >>= (`unless` loop) + req = HTTP2Client.requestNoBody "GET" path mempty closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m ChatResponse closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do - case session of - RemoteHostSessionStarting {announcer} -> cancel announcer - RemoteHostSessionStarted {ctrlClient} -> liftIO (HTTP2.closeHTTP2Client ctrlClient) + liftIO $ cancelRemoteHostSession session + chatWriteVar currentRemoteHost Nothing chatModifyVar remoteHostSessions $ M.delete remoteHostId - pure CRRemoteHostStopped { remoteHostId } + pure CRRemoteHostStopped {remoteHostId} + +cancelRemoteHostSession :: MonadUnliftIO m => RemoteHostSession -> m () +cancelRemoteHostSession = \case + RemoteHostSessionStarting {announcer} -> cancel announcer + RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient createRemoteHost :: (ChatMonad m) => m ChatResponse createRemoteHost = do @@ -87,10 +129,7 @@ createRemoteHost = do ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) displayName storePath <- liftIO randomStorePath remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath displayName caKey caCert - let oobData = - RemoteCtrlOOB - { caFingerprint = C.certificateFingerprint caCert - } + let oobData = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert} pure CRRemoteHostCreated {remoteHostId, oobData} -- | Generate a random 16-char filepath without / in it by using base64url encoding. @@ -113,41 +152,40 @@ deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \rh -> do pure CRRemoteHostDeleted {remoteHostId} processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse -processRemoteCommand RemoteHostSessionStarting {} _ = error "TODO: sending remote commands before session started" -processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = +processRemoteCommand RemoteHostSessionStarting {} _ = pure . CRChatError Nothing . ChatError $ CEInternalError "sending remote commands before session started" +processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = do + logDebug $ "processRemoteCommand: " <> T.pack (show s) -- XXX: intercept and filter some commands -- TODO: store missing files on remote host relayCommand ctrlClient s relayCommand :: (ChatMonad m) => HTTP2Client -> ByteString -> m ChatResponse relayCommand http s = - postBytestring Nothing http "/relay" mempty s >>= \case - Left e -> error "TODO: http2chatError" + postBytestring Nothing http "/send" mempty s >>= \case + Left e -> err $ "relayCommand/post: " <> show e Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - remoteChatResponse <- - if iTax - then case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> error "TODO: json2chatError" e - Right (raw :: J.Value) -> case J.fromJSON (sum2tagged raw) of - J.Error e -> error "TODO: json2chatError" e - J.Success cr -> pure cr - else case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> error "TODO: json2chatError" e - Right cr -> pure cr + logDebug $ "Got /send response: " <> T.pack (show bodyHead) + remoteChatResponse <- case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks + Left e -> err $ "relayCommand/decodeValue: " <> show e + Right json -> case J.fromJSON $ toTaggedJSON json of + J.Error e -> err $ "relayCommand/fromJSON: " <> show e + J.Success cr -> pure cr case remoteChatResponse of -- TODO: intercept file responses and fetch files when needed -- XXX: is that even possible, to have a file response to a command? _ -> pure remoteChatResponse where - iTax = True -- TODO: get from RemoteHost + err = pure . CRChatError Nothing . ChatError . CEInternalError + toTaggedJSON :: J.Value -> J.Value + toTaggedJSON = id -- owsf2tagged TODO: get from RemoteHost -- XXX: extract to http2 transport - postBytestring timeout c path hs body = liftIO $ HTTP2.sendRequest c req timeout + postBytestring timeout' c path hs body = liftIO $ HTTP2.sendRequestDirect c req timeout' where req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) -- | Convert swift single-field sum encoding into tagged/discriminator-field -sum2tagged :: J.Value -> J.Value -sum2tagged = \case +owsf2tagged :: J.Value -> J.Value +owsf2tagged = \case J.Object todo'convert -> J.Object todo'convert skip -> skip @@ -161,13 +199,13 @@ storeRemoteFile http localFile = do where postFile timeout c path hs file = liftIO $ do fileSize <- fromIntegral <$> getFileSize file - HTTP2.sendRequest c (req fileSize) timeout + HTTP2.sendRequestDirect c (req fileSize) timeout where - req size = HTTP2Client.requestFile "POST" path hs (HTTP2Client.FileSpec file 0 size) + req size = HTTP2Client.requestFile "PUT" path hs (HTTP2Client.FileSpec file 0 size) fetchRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> FileTransferId -> m ChatResponse fetchRemoteFile http storePath remoteFileId = do - liftIO (HTTP2.sendRequest http req Nothing) >>= \case + liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case Left e -> error "TODO: http2chatError" Right HTTP2.HTTP2Response {respBody} -> do error "TODO: stream body into a local file" -- XXX: consult headers for a file name? @@ -175,47 +213,84 @@ fetchRemoteFile http storePath remoteFileId = do req = HTTP2Client.requestNoBody "GET" path mempty path = "/fetch/" <> bshow remoteFileId -processControllerRequest :: (ChatMonad m) => RemoteCtrlId -> HTTP2.HTTP2Request -> m () -processControllerRequest rc req = error "TODO: processControllerRequest" +processControllerRequest :: forall m . (ChatMonad m) => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () +processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody = HTTP2Body {bodyHead}, sendResponse} = do + logDebug $ "Remote controller request: " <> T.pack (show $ method <> " " <> path) + res <- tryChatError $ case (method, path) of + ("GET", "/") -> getHello + ("POST", "/send") -> sendCommand + ("GET", "/recv") -> recvMessage + ("PUT", "/store") -> storeFile + ("GET", "/fetch") -> fetchFile + unexpected -> respondWith Status.badRequest400 $ "unexpected method/path: " <> Binary.putStringUtf8 (show unexpected) + case res of + Left e -> logError $ "Error handling remote controller request: (" <> tshow (method <> " " <> path) <> "): " <> tshow e + Right () -> logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) <> " OK" + where + method = fromMaybe "" $ HTTP2Server.requestMethod request + path = fromMaybe "" $ HTTP2Server.requestPath request + getHello = respond "OK" + sendCommand = execChatCommand bodyHead >>= respondJSON + recvMessage = chatReadVar remoteCtrlSession >>= \case + Nothing -> respondWith Status.internalServerError500 "session not active" + Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON + storeFile = respondWith Status.notImplemented501 "TODO: storeFile" + fetchFile = respondWith Status.notImplemented501 "TODO: fetchFile" + + respondJSON :: J.ToJSON a => a -> m () + respondJSON = respond . Binary.fromLazyByteString . J.encode + + respond = respondWith Status.ok200 + respondWith status = liftIO . sendResponse . HTTP2Server.responseBuilder status [] -- * ChatRequest handlers -startRemoteCtrl :: (ChatMonad m) => m ChatResponse -startRemoteCtrl = +startRemoteCtrl :: (ChatMonad m) => (ByteString -> m ChatResponse) -> m ChatResponse +startRemoteCtrl execChatCommand = chatReadVar remoteCtrlSession >>= \case Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy Nothing -> do - accepted <- newEmptyTMVarIO + size <- asks $ tbqSize . config + remoteOutputQ <- newTBQueueIO size + remoteNotifyQ <- newTBQueueIO size discovered <- newTVarIO mempty discoverer <- async $ discoverRemoteCtrls discovered + accepted <- newEmptyTMVarIO supervisor <- async $ do remoteCtrlId <- atomically (readTMVar accepted) withRemoteCtrl remoteCtrlId $ \RemoteCtrl {displayName, fingerprint} -> do source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure toView $ CRRemoteCtrlConnecting {remoteCtrlId, displayName} atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest remoteCtrlId) + server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} toView $ CRRemoteCtrlConnected {remoteCtrlId, displayName} _ <- waitCatch server chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlStopped {remoteCtrlId} - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted} + toView $ CRRemoteCtrlStopped Nothing + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ, remoteNotifyQ} pure $ CRRemoteCtrlStarted Nothing discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () -discoverRemoteCtrls discovered = Discovery.openListener >>= go +discoverRemoteCtrls discovered = Discovery.withListener go where go sock = Discovery.recvAnnounce sock >>= \case - (SockAddrInet _port addr, invite) -> case strDecode invite of + (SockAddrInet _sockPort sockAddr, invite) -> case strDecode invite of Left _ -> go sock -- ignore malformed datagrams Right fingerprint -> do - atomically $ TM.insert fingerprint (THIPv4 $ hostAddressToTuple addr) discovered + let addr = THIPv4 (hostAddressToTuple sockAddr) + ifM + (atomically $ TM.member fingerprint discovered) + (logDebug $ "Fingerprint announce already knwon: " <> T.pack (show (addr, invite))) + (do + logInfo $ "New fingerprint announce: " <> T.pack (show (addr, invite)) + atomically $ TM.insert fingerprint addr discovered + ) withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case - Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui action required + Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required Just found@RemoteCtrl {remoteCtrlId, accepted=storedChoice} -> case storedChoice of - Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui action required + Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui "accept" action required Just False -> pure () -- skipping a rejected item Just True -> chatReadVar remoteCtrlSession >>= \case Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" @@ -258,20 +333,28 @@ rejectRemoteCtrl remoteCtrlId = do cancel supervisor pure $ CRRemoteCtrlRejected {remoteCtrlId} -stopRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse -stopRemoteCtrl remoteCtrlId = +stopRemoteCtrl :: (ChatMonad m) => m ChatResponse +stopRemoteCtrl = chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {discoverer, supervisor, hostServer} -> do - cancel discoverer -- may be gone by now - case hostServer of - Just host -> cancel host -- supervisor will clean up - Nothing -> do - cancel supervisor -- supervisor is blocked until session progresses - chatWriteVar remoteCtrlSession Nothing - toView $ CRRemoteCtrlStopped {remoteCtrlId} + Just rcs -> do + cancelRemoteCtrlSession rcs $ do + chatWriteVar remoteCtrlSession Nothing + toView $ CRRemoteCtrlStopped Nothing pure $ CRCmdOk Nothing +cancelRemoteCtrlSession_ :: MonadUnliftIO m => RemoteCtrlSession -> m () +cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () + +cancelRemoteCtrlSession :: MonadUnliftIO m => RemoteCtrlSession -> m () -> m () +cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} cleanup = do + cancel discoverer -- may be gone by now + case hostServer of + Just host -> cancel host -- supervisor will clean up + Nothing -> do + cancel supervisor -- supervisor is blocked until session progresses + cleanup + deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse deleteRemoteCtrl remoteCtrlId = chatReadVar remoteCtrlSession >>= \case diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index 2faed66cd8..40314b4cb5 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Remote.Discovery -- * Discovery connectRevHTTP2, + withListener, openListener, recvAnnounce, connectTLSClient, @@ -32,7 +33,7 @@ import Simplex.Messaging.Transport (supportedParameters) import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportClientConfig, runTransportClient) import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, defaultHTTP2ClientConfig) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) import Simplex.Messaging.Util (whenM) @@ -52,15 +53,16 @@ pattern BROADCAST_PORT = "5226" -- | Announce tls server, wait for connection and attach http2 client to it. -- -- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: (StrEncoding invite, MonadUnliftIO m) => IO () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 :: (StrEncoding invite, MonadUnliftIO m) => m () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) announceRevHTTP2 finishAction invite credentials = do httpClient <- newEmptyMVar started <- newEmptyTMVarIO finished <- newEmptyMVar announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ runAnnouncer (strEncode invite) tlsServer <- startTLSServer started credentials $ \tls -> cancel announcer >> runHTTP2Client finished httpClient tls - _ <- forkIO . liftIO $ do + _ <- forkIO $ do readMVar finished + cancel announcer cancel tlsServer finishAction readMVar httpClient @@ -68,11 +70,12 @@ announceRevHTTP2 finishAction invite credentials = do -- | Broadcast invite with link-local datagrams runAnnouncer :: ByteString -> IO () runAnnouncer inviteBS = do - sock <- UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False - N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 - forever $ do - UDP.send sock inviteBS - threadDelay 1000000 + bracket (UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False) UDP.close $ \sock -> do + N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 + N.setSocketOption (UDP.udpSocket sock) N.ReuseAddr 1 + forever $ do + UDP.send sock inviteBS + threadDelay 1000000 startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig @@ -88,8 +91,13 @@ startTLSServer started credentials = async . liftIO . runTransportServer started -- | Attach HTTP2 client and hold the TLS until the attached client finishes. runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () runHTTP2Client finishedVar clientVar tls = do - attachHTTP2Client defaultHTTP2ClientConfig ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls >>= putMVar clientVar + attachHTTP2Client config ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls >>= putMVar clientVar readMVar finishedVar + where + config = defaultHTTP2ClientConfig { connTimeout = 86400000000 } + +withListener :: (MonadUnliftIO m) => (UDP.ListenSocket -> m a) -> m a +withListener = bracket openListener (liftIO . UDP.stop) openListener :: (MonadIO m) => m UDP.ListenSocket openListener = liftIO $ do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index f13c3c84ea..cdff2b7acc 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -5,15 +5,10 @@ module Simplex.Chat.Remote.Types where -import Control.Concurrent.Async (Async) import qualified Data.Aeson.TH as J import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.TMap (TMap) -import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) -import UnliftIO.STM type RemoteHostId = Int64 @@ -40,22 +35,3 @@ data RemoteCtrl = RemoteCtrl deriving (Show) $(J.deriveJSON J.defaultOptions ''RemoteCtrl) - -data RemoteHostSession - = RemoteHostSessionStarting - { announcer :: Async () - } - | RemoteHostSessionStarted - { -- | Path for local resources to be synchronized with host - storePath :: FilePath, - ctrlClient :: HTTP2Client - } - -data RemoteCtrlSession = RemoteCtrlSession - { -- | Server side of transport to process remote commands and forward notifications - discoverer :: Async (), - supervisor :: Async (), - hostServer :: Maybe (Async ()), - discovered :: TMap C.KeyHash TransportHost, - accepted :: TMVar RemoteCtrlId - } diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 1097a79543..4a73a0fd72 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -53,15 +53,16 @@ getKey = runInputLoop :: ChatTerminal -> ChatController -> IO () runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do s <- atomically . readTBQueue $ inputQ cc + rh <- readTVarIO $ currentRemoteHost cc let bs = encodeUtf8 $ T.pack s cmd = parseChatCommand bs unless (isMessage cmd) $ echo s - r <- runReaderT (execChatCommand Nothing bs) cc + r <- runReaderT (execChatCommand rh bs) cc case r of CRChatCmdError _ _ -> when (isMessage cmd) $ echo s CRChatError _ _ -> when (isMessage cmd) $ echo s _ -> pure () - printRespToTerminal ct cc False r + printRespToTerminal ct cc False rh r startLiveMessage cmd r where echo s = printToTerminal ct [plain s] @@ -134,7 +135,7 @@ runTerminalInput ct cc = withChatTerm ct $ do receiveFromTTY cc ct receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m () -receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState} = +receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState} = forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct) where processKey :: (Key, Modifiers) -> IO () @@ -166,7 +167,8 @@ receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@C kill promptThreadId atomically $ writeTVar liveMessageState Nothing r <- sendUpdatedLiveMessage cc sentMsg lm False - printRespToTerminal ct cc False r + rh <- readTVarIO currentRemoteHost -- XXX: should be inherited from live message state + printRespToTerminal ct cc False rh r where kill sel = deRefWeak (sel lm) >>= mapM_ killThread diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 74bb9e8c0b..e6792129cb 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -21,6 +21,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Styled import Simplex.Chat.View +import Simplex.Chat.Remote.Types (RemoteHostId) import System.Console.ANSI.Types import System.IO (IOMode (..), hPutStrLn, withFile) import System.Mem.Weak (Weak) @@ -112,7 +113,7 @@ withTermLock ChatTerminal {termLock} action = do runTerminalOutput :: ChatTerminal -> ChatController -> IO () runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = do forever $ do - (_, _, r) <- atomically $ readTBQueue outputQ + (_, outputRH, r) <- atomically $ readTBQueue outputQ case r of CRNewChatItem _ ci -> markChatItemRead ci CRChatItemUpdated _ ci -> markChatItemRead ci @@ -121,7 +122,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d Just path -> if logResponseToFile r then logResponse path else printToTerminal ct _ -> printToTerminal ct liveItems <- readTVarIO showLiveItems - responseString cc liveItems r >>= printResp + responseString cc liveItems outputRH r >>= printResp where markChatItemRead (AChatItem _ _ chat item@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = case (muted chat chatDir, itemStatus) of @@ -132,15 +133,16 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s -printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> ChatResponse -> IO () -printRespToTerminal ct cc liveItems r = responseString cc liveItems r >>= printToTerminal ct +printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO () +printRespToTerminal ct cc liveItems outputRH r = responseString cc liveItems outputRH r >>= printToTerminal ct -responseString :: ChatController -> Bool -> ChatResponse -> IO [StyledString] -responseString cc liveItems r = do - user <- readTVarIO $ currentUser cc +responseString :: ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO [StyledString] +responseString cc liveItems outputRH r = do + currentRH <- readTVarIO $ currentRemoteHost cc + user <- readTVarIO $ currentUser cc -- XXX: local user, should be subsumed by remote when connected ts <- getCurrentTime tz <- getCurrentTimeZone - pure $ responseToView user (config cc) liveItems ts tz r + pure $ responseToView (currentRH, user) (config cc) liveItems ts tz outputRH r printToTerminal :: ChatTerminal -> [StyledString] -> IO () printToTerminal ct s = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 088f23e056..03e0135e78 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1442,6 +1442,7 @@ serializeIntroStatus = \case GMIntroConnected -> "con" data Notification = Notification {title :: Text, text :: Text} + deriving (Show, Generic, FromJSON, ToJSON) textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f32b1835ed..f98406d1ba 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -66,11 +66,11 @@ import System.Console.ANSI.Types type CurrentTime = UTCTime -serializeChatResponse :: Maybe User -> CurrentTime -> TimeZone -> ChatResponse -> String -serializeChatResponse user_ ts tz = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz +serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> String +serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ -responseToView :: Maybe User -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> ChatResponse -> [StyledString] -responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz = \case +responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] +responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRUsersList users -> viewUsersList users CRChatStarted _ -> ["chat started"] @@ -274,7 +274,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRRemoteCtrlRejected rcId -> ["remote controller " <> sShow rcId <> " rejected"] CRRemoteCtrlConnecting rcId rcName -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] CRRemoteCtrlConnected rcId rcName -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] - CRRemoteCtrlStopped rcId -> ["remote controller " <> sShow rcId <> " stopped"] + CRRemoteCtrlStopped _ -> ["remote controller stopped"] CRRemoteCtrlDeleted rcId -> ["remote controller " <> sShow rcId <> " deleted"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> @@ -323,12 +323,14 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView | otherwise = [] ttyUserPrefix :: User -> [StyledString] -> [StyledString] ttyUserPrefix _ [] = [] - ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst userPrefix ss + ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst prefix ss where - userPrefix = case user_ of - Just User {userId = activeUserId} -> if userId /= activeUserId then prefix else "" - _ -> prefix - prefix = "[user: " <> highlight u <> "] " + prefix = if outputRH /= currentRH then r else userPrefix + r = case outputRH of + Nothing -> "[local] " <> userPrefix + Just rh -> "[remote: ]" <> highlight (show rh) <> "] " + userPrefix = if Just userId /= currentUserId then "[user: " <> highlight u <> "] " else "" + currentUserId = fmap (\User {userId} -> userId) user_ ttyUser' :: Maybe User -> [StyledString] -> [StyledString] ttyUser' = maybe id ttyUser ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString] diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 6831cf3190..107faef729 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -34,6 +34,9 @@ import Test.Hspec defaultPrefs :: Maybe Preferences defaultPrefs = Just $ toChatPrefs defaultChatPrefs +aliceDesktopProfile :: Profile +aliceDesktopProfile = Profile {displayName = "alice_desktop", fullName = "Alice Desktop", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} + aliceProfile :: Profile aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index d1c162187f..f9137cdbaf 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -31,7 +31,8 @@ remoteTests :: SpecWith FilePath remoteTests = describe "Handshake" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test - it "connects desktop and mobile" remoteHandshakeTest + xit "connects desktop and mobile" remoteHandshakeTest + it "send messages via remote desktop" remoteCommandTest -- * Low-level TLS with ephemeral credentials @@ -39,11 +40,10 @@ genCredentialsTest :: (HasCallStack) => FilePath -> IO () genCredentialsTest _tmp = do (fingerprint, credentials) <- genTestCredentials started <- newEmptyTMVarIO - server <- Discovery.startTLSServer started credentials serverHandler - ok <- atomically (readTMVar started) - unless ok $ cancel server >> error "TLS server failed to start" - Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler - cancel server + bracket (Discovery.startTLSServer started credentials serverHandler) cancel $ \_server -> do + ok <- atomically (readTMVar started) + unless ok $ error "TLS server failed to start" + Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler where serverHandler serverTls = do traceM " - Sending from server" @@ -62,19 +62,21 @@ announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () announceDiscoverHttp2Test _tmp = do (fingerprint, credentials) <- genTestCredentials finished <- newEmptyMVar - announcer <- async $ do + controller <- async $ do traceM " - Controller: starting" - http <- Discovery.announceRevHTTP2 (putMVar finished ()) fingerprint credentials >>= either (fail . show) pure - traceM " - Controller: got client" - sendRequest http (C.requestNoBody "GET" "/" []) (Just 10000000) >>= \case - Left err -> do - traceM " - Controller: got error" - fail $ show err - Right HTTP2Response {} -> - traceM " - Controller: got response" - closeHTTP2Client http - dis <- async $ do - sock <- Discovery.openListener + bracket + (Discovery.announceRevHTTP2 (putMVar finished ()) fingerprint credentials >>= either (fail . show) pure) + closeHTTP2Client + ( \http -> do + traceM " - Controller: got client" + sendRequest http (C.requestNoBody "GET" "/" []) (Just 10000000) >>= \case + Left err -> do + traceM " - Controller: got error" + fail $ show err + Right HTTP2Response {} -> + traceM " - Controller: got response" + ) + host <- async $ Discovery.withListener $ \sock -> do (N.SockAddrInet _port addr, invite) <- Discovery.recvAnnounce sock strDecode invite `shouldBe` Right fingerprint traceM " - Host: connecting" @@ -84,14 +86,13 @@ announceDiscoverHttp2Test _tmp = do traceM " - Host: got request" sendResponse $ S.responseNoBody ok200 [] traceM " - Host: sent response" - takeMVar finished - cancel server + takeMVar finished `finally` cancel server traceM " - Host: finished" - waitBoth dis announcer `shouldReturn` ((), ()) + (waitBoth host controller `shouldReturn` ((), ())) `onException` (cancel host >> cancel controller) -- * Chat commands -remoteHandshakeTest :: HasCallStack => FilePath -> IO () +remoteHandshakeTest :: (HasCallStack) => FilePath -> IO () remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" @@ -103,7 +104,6 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop ##> "/list remote hosts" desktop <## "Remote hosts:" desktop <## "1. TODO" -- TODO host name probably should be Maybe, as when host is created there is no name yet - desktop ##> "/start remote host 1" desktop <## "remote host 1 started" @@ -124,9 +124,9 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start mobile <## "remote controller 1 connecting to TODO" mobile <## "remote controller 1 connected, TODO" - mobile ##> "/stop remote ctrl 1" + mobile ##> "/stop remote ctrl" mobile <## "ok" - mobile <## "remote controller 1 stopped" -- TODO two outputs + mobile <## "remote controller stopped" mobile ##> "/delete remote ctrl 1" mobile <## "remote controller 1 deleted" mobile ##> "/list remote ctrls" @@ -139,6 +139,56 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" +remoteCommandTest :: (HasCallStack) => FilePath -> IO () +remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do + desktop ##> "/create remote host" + desktop <## "remote host 1 created" + desktop <## "connection code:" + fingerprint <- getTermLine desktop + + desktop ##> "/start remote host 1" + desktop <## "remote host 1 started" + + mobile ##> "/start remote ctrl" + mobile <## "remote controller started" + mobile <## "remote controller announced" + mobile <## "connection code:" + fingerprint' <- getTermLine mobile + fingerprint' `shouldBe` fingerprint + mobile ##> ("/register remote ctrl " <> fingerprint') + mobile <## "remote controller 1 registered" + mobile ##> "/accept remote ctrl 1" + mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start + mobile <## "remote controller 1 connecting to TODO" + mobile <## "remote controller 1 connected, TODO" + desktop <## "remote host 1 connected" + + traceM " - exchanging contacts" + bob ##> "/c" + inv' <- getInvitation bob + desktop ##> ("/c " <> inv') + desktop <## "confirmation sent!" + concurrently_ + (desktop <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + + traceM " - sending messages" + desktop #> "@bob hello there 🙂" + bob <# "alice> hello there 🙂" + bob #> "@alice hi" + desktop <# "bob> hi" + + traceM " - post-remote checks" + mobile ##> "/stop remote ctrl" + mobile <## "ok" + concurrently_ + (mobile <## "remote controller stopped") + (desktop <## "remote host 1 stopped") + mobile ##> "/contacts" + mobile <## "bob (Bob)" + + traceM " - done" + -- * Utils genTestCredentials :: IO (C.KeyHash, TLS.Credentials) From d50c7ad7f63322f7651848b0a2d49b0c1f42ac35 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 8 Oct 2023 15:37:28 +0800 Subject: [PATCH 046/294] bump hackage (#3185) --- flake.lock | 6 +++--- flake.nix | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index 2c133a9b63..81b3f6f6b8 100644 --- a/flake.lock +++ b/flake.lock @@ -288,11 +288,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1676679913, - "narHash": "sha256-nW7ApRgiA9uChV/UrW89HK75rIToLt7XtSrkodO0Nbc=", + "lastModified": 1696724662, + "narHash": "sha256-jV2ugSjZE0FjMYR2YIx0p2cDBqd+xxhZrRxp5BmieYk=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "c37cffd51315d8e27dd8d3faf75abf897e39c8c8", + "rev": "df603bff8606d8653a0876ae0c3fd1f9014882f2", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f7e86fbb6f..f6a68246e2 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,7 @@ let pkgs = haskellNix.legacyPackages.${system}.appendOverlays [android26]; in let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { compiler-nix-name = "ghc8107"; - index-state = "2022-06-20T00:00:00Z"; + index-state = "2023-10-06T00:00:00Z"; # We need this, to specify we want the cabal project. # If the stack.yaml was dropped, this would not be necessary. projectFileName = "cabal.project"; From ab46cbc5dd5c77838002a7ebdb2263e39e43658f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 9 Oct 2023 09:46:58 +0400 Subject: [PATCH 047/294] core: relax contact probing: don't send probe hashes to new contacts except group link hosts; still send probe hashes to group members (#3180) --- src/Simplex/Chat.hs | 95 +++++----- src/Simplex/Chat/Store/Direct.hs | 9 +- src/Simplex/Chat/Store/Groups.hs | 136 ++++++--------- src/Simplex/Chat/Store/Messages.hs | 3 +- tests/ChatTests/Direct.hs | 101 +++++------ tests/ChatTests/Groups.hs | 271 +++-------------------------- tests/ChatTests/Profiles.hs | 34 ---- 7 files changed, 176 insertions(+), 473 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 32d59c9cc5..296abf0e2b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -39,7 +39,6 @@ import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, foldl', isSuffixOf, partition, sortOn) -import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty, nonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -3106,7 +3105,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setActive $ ActiveC c showToast (c <> "> ") "connected" when (contactConnInitiated conn) $ do - probeMatchingContactsAndMembers ct (contactConnIncognito ct) + let Connection {groupLinkId} = conn + doProbeContacts = isJust groupLinkId + probeMatchingContactsAndMembers ct (contactConnIncognito ct) doProbeContacts withStore' $ \db -> resetContactConnInitiated db user conn forM_ viaUserContactLink $ \userContactLinkId -> withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case @@ -3125,7 +3126,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True SENT msgId -> do sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId @@ -3143,8 +3144,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> setConnectionVerified db user connId Nothing let ct' = ct {activeConn = conn {connectionCode = Nothing}} :: Contact ratchetSyncEventItem ct' - toView $ CRContactVerificationReset user ct' - createInternalChatItem user (CDDirectRcv ct') (CIRcvConnEvent RCEVerificationCodeReset) Nothing + securityCodeChanged ct' _ -> ratchetSyncEventItem ct where processErr cryptoErr = do @@ -3298,7 +3298,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do @@ -3691,8 +3691,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" - probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> m () - probeMatchingContactsAndMembers ct connectedIncognito = do + probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () + probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do gVar <- asks idsDrg contactMerge <- readTVarIO =<< asks contactMergeEnabled if contactMerge && not connectedIncognito @@ -3703,7 +3703,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- sendProbe -> sendProbeHashes (currently) -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - cs <- map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + cs <- if doProbeContacts + then map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + else pure [] ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db user ct) sendProbeHashes (cs <> ms) probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) @@ -4363,32 +4365,18 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do COMContact <$$> mergeContacts c1 c2 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" >> pure Nothing COMGroupMember m2@GroupMember {memberProfile = p2, memberContactId} - | profilesMatch p1 p2 -> case memberContactId of - Nothing -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - COMContact <$$> associateMemberAndContact c1 m2 - Just mCtId - | mCtId /= cId1 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - mCt <- withStore $ \db -> getContact db user mCtId - COMContact <$$> mergeContacts c1 mCt - | otherwise -> messageWarning "probeMatch ignored: same contact id" >> pure Nothing - | otherwise -> messageWarning "probeMatch ignored: profiles don't match" >> pure Nothing + | isNothing memberContactId && profilesMatch p1 p2 -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + COMContact <$$> associateMemberAndContact c1 m2 + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" >> pure Nothing COMGroupMember GroupMember {activeConn = Nothing} -> pure Nothing COMGroupMember m1@GroupMember {groupId, memberProfile = p1, memberContactId, activeConn = Just conn} -> case cgm2 of - COMContact c2@Contact {contactId = cId2, profile = p2} - | memberCurrent m1 && profilesMatch p1 p2 -> case memberContactId of - Nothing -> do - void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) - COMContact <$$> associateMemberAndContact c2 m1 - Just mCtId - | mCtId /= cId2 -> do - void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) - mCt <- withStore $ \db -> getContact db user mCtId - COMContact <$$> mergeContacts c2 mCt - | otherwise -> messageWarning "probeMatch ignored: same contact id" >> pure Nothing - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member not current" >> pure Nothing + COMContact c2@Contact {profile = p2} + | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> do + void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) + COMContact <$$> associateMemberAndContact c2 m1 + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact or member not current" >> pure Nothing COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing xInfoProbeOk :: ContactOrMember -> Probe -> m () @@ -4400,24 +4388,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Just (COMContact c2@Contact {contactId = cId2}) | cId1 /= cId2 -> void $ mergeContacts c1 c2 | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" - Just (COMGroupMember m2@GroupMember {memberContactId}) -> - case memberContactId of - Nothing -> void $ associateMemberAndContact c1 m2 - Just mCtId - | mCtId /= cId1 -> do - mCt <- withStore $ \db -> getContact db user mCtId - void $ mergeContacts c1 mCt - | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + Just (COMGroupMember m2@GroupMember {memberContactId}) + | isNothing memberContactId -> void $ associateMemberAndContact c1 m2 + | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" _ -> pure () COMGroupMember m1@GroupMember {memberContactId} -> case cgm2 of - Just (COMContact c2@Contact {contactId = cId2}) -> case memberContactId of - Nothing -> void $ associateMemberAndContact c2 m1 - Just mCtId - | mCtId /= cId2 -> do - mCt <- withStore $ \db -> getContact db user mCtId - void $ mergeContacts c2 mCt - | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + Just (COMContact c2) + | isNothing memberContactId -> void $ associateMemberAndContact c2 m1 + | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" Just (COMGroupMember _) -> messageWarning "xInfoProbeOk ignored: members are not matched with members" _ -> pure () @@ -4540,7 +4519,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do merge c1' c2' = do c2'' <- withStore $ \db -> mergeContactRecords db user c1' c2' toView $ CRContactsMerged user c1' c2' c2'' + when (directOrUsed c2'') $ showSecurityCodeChanged c2'' pure $ Just c2'' + where + showSecurityCodeChanged mergedCt = do + let sc1_ = contactSecurityCode c1' + sc2_ = contactSecurityCode c2' + scMerged_ = contactSecurityCode mergedCt + case (sc1_, sc2_) of + (Just sc1, Nothing) + | scMerged_ /= Just sc1 -> securityCodeChanged mergedCt + | otherwise -> pure () + (Nothing, Just sc2) + | scMerged_ /= Just sc2 -> securityCodeChanged mergedCt + | otherwise -> pure () + _ -> pure () associateMemberAndContact :: Contact -> GroupMember -> m (Maybe Contact) associateMemberAndContact c m = do @@ -4796,9 +4789,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ mContent_ $ \mc -> do ci <- saveRcvChatItem user (CDDirectRcv mCt') msg msgMeta (CIRcvMsgContent mc) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat mCt') ci) - securityCodeChanged ct = do - toView $ CRContactVerificationReset user ct - createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing + + securityCodeChanged :: Contact -> m () + securityCodeChanged ct = do + toView $ CRContactVerificationReset user ct + createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 20b81def8f..91243e2319 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -159,7 +159,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.deleted = 0 - ORDER BY c.connection_id DESC + ORDER BY c.created_at DESC LIMIT 1 |] (userId, cReqHash) @@ -517,7 +517,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.connection_id DESC + ORDER BY c.created_at DESC LIMIT 1 |] (userId, xContactId) @@ -667,7 +667,7 @@ getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO C getContact_ db user@User {userId} contactId deleted = ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $ DB.query - db + db [sql| SELECT -- Contact @@ -686,10 +686,11 @@ getContact_ db user@User {userId} contactId deleted = SELECT cc_connection_id FROM ( SELECT cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord FROM connections cc WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC LIMIT 1 ) ) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index f6a4233c1f..236031da94 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -107,7 +107,7 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Either (rights) import Data.Int (Int64) -import Data.List (sortOn) +import Data.List (partition, sortOn) import Data.Maybe (fromMaybe, isNothing, catMaybes, isJust) import Data.Ord (Down (..)) import Data.Text (Text) @@ -695,31 +695,21 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co ) getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact -getContactViaMember db user@User {userId} GroupMember {groupMemberId} = - ExceptT $ - firstRow (toContact user) (SEContactNotFoundByMemberId groupMemberId) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.contact_id = ct.contact_id - ) - JOIN group_members m ON m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - |] - (userId, groupMemberId) +getContactViaMember db user@User {userId} GroupMember {groupMemberId} = do + contactId <- + ExceptT $ + firstRow fromOnly (SEContactNotFoundByMemberId groupMemberId) $ + DB.query + db + [sql| + SELECT ct.contact_id + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + |] + (userId, groupMemberId) + getContact db user contactId setNewContactMemberConnRequest :: DB.Connection -> User -> GroupMember -> ConnReqInvitation -> IO () setNewContactMemberConnRequest db User {userId} GroupMember {groupMemberId} connRequest = do @@ -1041,37 +1031,21 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) -getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = - maybeFirstRow toContact' $ - DB.query - db - [sql| - SELECT - ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.contact_id = ct.contact_id - ) - JOIN groups g ON g.group_id = ct.via_group - JOIN group_members m ON m.group_id = g.group_id AND m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - |] - (userId, groupMemberId) - where - toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact - toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = - let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} - activeConn = toConnection connRow - mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} +getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do + contactId_ <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT ct.contact_id + FROM group_members m + JOIN groups g ON g.group_id = m.group_id + JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + |] + (userId, groupMemberId) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) contactId_ updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} @@ -1258,7 +1232,16 @@ matchReceivedProbe db user@User {userId} from (Probe probe) = do db "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (ctId, gmId, probe, probeHash, userId, currentTs, currentTs) - catMaybes <$> mapM (getContactOrMember_ db user) cgmIds + let cgmIds' = filterFirstContactId cgmIds + catMaybes <$> mapM (getContactOrMember_ db user) cgmIds' + where + filterFirstContactId :: [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] -> [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] + filterFirstContactId cgmIds = do + let (ctIds, memIds) = partition (\(ctId, _, _) -> isJust ctId) cgmIds + ctIds' = case ctIds of + [] -> [] + (x : _) -> [x] + ctIds' <> memIds matchReceivedProbeHash :: DB.Connection -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do @@ -1284,7 +1267,7 @@ matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db user cgmIds matchSentProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) -matchSentProbe db user@User {userId} _from (Probe probe) = +matchSentProbe db user@User {userId} _from (Probe probe) = do cgmIds $>>= getContactOrMember_ db user where (ctId, gmId) = contactOrMemberIds _from @@ -1311,11 +1294,10 @@ getContactOrMember_ db user ids = (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db user gId gmId _ -> throwError $ SEInternalError "" --- connection being verified and connection level 0 have priority over requested merge direction; --- if requested merge direction is overruled, keepLDN is kept +-- if requested merge direction is overruled (toFromContacts), keepLDN is kept mergeContactRecords :: DB.Connection -> User -> Contact -> Contact -> ExceptT StoreError IO Contact mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN} from = do - let (toCt, fromCt) = checkToFromContacts + let (toCt, fromCt) = toFromContacts to from Contact {contactId = toContactId, localDisplayName = toLDN} = toCt Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt liftIO $ do @@ -1342,18 +1324,6 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN db "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE sent_probes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE sent_probe_hashes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE received_probes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) DB.executeNamed db [sql| @@ -1384,16 +1354,16 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN (keepLDN, currentTs, userId, toLDN) getContact db user toContactId where - checkToFromContacts :: (Contact, Contact) - checkToFromContacts - | vrfFrom && not vrfTo = (from, to) - | dirFrom && not vrfTo && not dirTo = (from, to) - | otherwise = (to, from) + toFromContacts :: Contact -> Contact -> (Contact, Contact) + toFromContacts c1 c2 + | d1 && not d2 = (c1, c2) + | d2 && not d1 = (c2, c1) + | ctCreatedAt c1 <= ctCreatedAt c2 = (c1, c2) + | otherwise = (c2, c1) where - vrfTo = isJust $ contactSecurityCode to - vrfFrom = isJust $ contactSecurityCode from - dirTo = let Contact {activeConn = Connection {connLevel = clTo}} = to in clTo == 0 - dirFrom = let Contact {activeConn = Connection {connLevel = clFrom}} = from in clFrom == 0 + d1 = directOrUsed c1 + d2 = directOrUsed c2 + ctCreatedAt Contact {createdAt} = createdAt associateMemberWithContactRecord :: DB.Connection -> User -> Contact -> GroupMember -> IO () associateMemberWithContactRecord diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b996f7626f..9ad0e8edc8 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -517,10 +517,11 @@ getDirectChatPreviews_ db user@User {userId} = do SELECT cc_connection_id FROM ( SELECT cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord FROM connections cc WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC LIMIT 1 ) ) diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index a8f9fbf9c6..a5fc7455c8 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -41,9 +41,9 @@ chatDirectTests = do it "direct timed message" testDirectTimedMessage it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact it "should send multiline message" testMultilineMessage - describe "contact merge" $ do - it "merge duplicate contacts" testContactMerge - it "new contact should merge with multiple existing contacts" testMergeContactMultipleContacts + describe "duplicate contacts" $ do + it "duplicate contacts are separate (contacts don't merge)" testDuplicateContactsSeparate + it "new contact is separate with multiple duplicate contacts (contacts don't merge)" testDuplicateContactsMultipleSeparate describe "SMP servers" $ do it "get and set SMP servers" testGetSetSMPServers it "test SMP server connection" testTestSMPServerConnection @@ -170,44 +170,13 @@ testAddContact = versionTestMatrix2 runTestAddContact alice #$> ("/_read chat @2", id, "ok") bob #$> ("/_read chat @2", id, "ok") -testContactMerge :: HasCallStack => FilePath -> IO () -testContactMerge = +testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsSeparate = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob alice <##> bob - alice ##> "/c" - inv' <- getInvitation alice - bob ##> ("/c " <> inv') - bob <## "confirmation sent!" - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages" - ] - ] - alice <##> bob - alice @@@ [("@bob", "hey")] - alice `hasContactProfiles` ["alice", "bob"] - bob @@@ [("@alice", "hey")] - bob `hasContactProfiles` ["bob", "alice"] - -testMergeContactMultipleContacts :: HasCallStack => FilePath -> IO () -testMergeContactMultipleContacts = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - bob ##> "/contact_merge off" - bob <## "ok" - - connectUsers alice bob - alice ##> "/c" inv' <- getInvitation alice bob ##> ("/c " <> inv') @@ -216,42 +185,56 @@ testMergeContactMultipleContacts = (alice <## "bob_1 (Bob): contact is connected") (bob <## "alice_1 (Alice): contact is connected") + alice <##> bob + alice #> "@bob_1 1" + bob <# "alice_1> 1" + bob #> "@alice_1 2" + alice <# "bob_1> 2" + + alice @@@ [("@bob", "hey"), ("@bob_1", "2")] alice `hasContactProfiles` ["alice", "bob", "bob"] + bob @@@ [("@alice", "hey"), ("@alice_1", "2")] bob `hasContactProfiles` ["bob", "alice", "alice"] - threadDelay 500000 +testDuplicateContactsMultipleSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsMultipleSeparate = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob - bob ##> "/contact_merge on" - bob <## "ok" + alice ##> "/c" + inv' <- getInvitation alice + bob ##> ("/c " <> inv') + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob_1 (Bob): contact is connected") + (bob <## "alice_1 (Alice): contact is connected") alice ##> "/c" inv'' <- getInvitation alice bob ##> ("/c " <> inv'') bob <## "confirmation sent!" - concurrentlyN_ - [ alice - <### [ "bob_2 (Bob): contact is connected", - StartsWith "contact bob_2 is merged into bob", - StartsWith "use @bob", - StartsWith "contact bob_1 is merged into bob", - StartsWith "use @bob" - ], - bob - <### [ "alice_2 (Alice): contact is connected", - StartsWith "contact alice_2 is merged into alice", - StartsWith "use @alice", - StartsWith "contact alice_1 is merged into alice", - StartsWith "use @alice" - ] - ] + concurrently_ + (alice <## "bob_2 (Bob): contact is connected") + (bob <## "alice_2 (Alice): contact is connected") + alice <##> bob + alice #> "@bob_1 1" + bob <# "alice_1> 1" + bob #> "@alice_1 2" + alice <# "bob_1> 2" + alice #> "@bob_2 3" + bob <# "alice_2> 3" + bob #> "@alice_2 4" + alice <# "bob_2> 4" alice ##> "/contacts" - alice <## "bob (Bob)" + alice <### ["bob (Bob)", "bob_1 (Bob)", "bob_2 (Bob)"] bob ##> "/contacts" - bob <## "alice (Alice)" - alice `hasContactProfiles` ["alice", "bob"] - bob `hasContactProfiles` ["bob", "alice"] + bob <### ["alice (Alice)", "alice_1 (Alice)", "alice_2 (Alice)"] + alice `hasContactProfiles` ["alice", "bob", "bob", "bob"] + bob `hasContactProfiles` ["bob", "alice", "alice", "alice"] testContactClear :: HasCallStack => FilePath -> IO () testContactClear = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 083d2f85be..9fb6ac7f9b 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -75,12 +75,9 @@ chatGroupTests = do it "members have different local display names in different groups" testNoDirectDifferentLDNs describe "merge members and contacts" $ do it "new member should merge with existing contact" testMergeMemberExistingContact - it "new member should merge with multiple existing contacts" testMergeMemberMultipleContacts it "new contact should merge with existing member" testMergeContactExistingMember - it "new contact should merge with existing member with associated contact" testMergeContactExistingMemberWithContact it "new contact should merge with multiple existing members" testMergeContactMultipleMembers - it "new contact should merge with both existing members and contacts" testMergeContactExistingMembersAndContacts - it "new member contact is merged with existing contact" testMergeMemberContact + it "new group link host contact should merge with single existing contact out of multiple" testMergeGroupLinkHostMultipleContacts describe "create member contact" $ do it "create contact with group member with invitation message" testMemberContactMessage it "create contact with group member without invitation message" testMemberContactNoMessage @@ -2788,76 +2785,6 @@ testMergeMemberExistingContact = alice `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeMemberMultipleContacts :: HasCallStack => FilePath -> IO () -testMergeMemberMultipleContacts = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - alice ##> "/contact_merge off" - alice <## "ok" - - connectUsers alice bob - connectUsers alice cath - - alice ##> "/c" - inv' <- getInvitation alice - cath ##> ("/c " <> inv') - cath <## "confirmation sent!" - concurrently_ - (alice <## "cath_1 (Catherine): contact is connected") - (cath <## "alice_1 (Alice): contact is connected") - - alice `hasContactProfiles` ["alice", "bob", "cath", "cath"] - cath `hasContactProfiles` ["cath", "alice", "alice"] - - alice ##> "/contact_merge on" - alice <## "ok" - - createGroup2 "team" bob cath - bob ##> "/a #team alice" - bob <## "invitation to join the group #team sent to alice" - alice <## "#team: bob invites you to join the group as member" - alice <## "use /j team to accept" - alice ##> "/j team" - concurrentlyN_ - [ alice - <### [ "#team: you joined the group", - "#team: member cath_2 (Catherine) is connected", - StartsWith "contact and member are merged: cath", - StartsWith "use @cath", - StartsWith "contact cath_", - StartsWith "use @cath" - ], - bob <## "#team: alice joined the group", - cath - <### [ "#team: bob added alice_2 (Alice) to the group (connecting...)", - "#team: new member alice_2 is connected", - StartsWith "contact and member are merged: alice", - StartsWith "use @alice", - StartsWith "contact alice_", - StartsWith "use @alice" - ] - ] - alice <##> cath - alice #> "#team hello" - bob <# "#team alice> hello" - cath <# "#team alice> hello" - cath #> "#team hello too" - bob <# "#team cath> hello too" - alice <# "#team cath> hello too" - - alice ##> "/contacts" - alice - <### [ "bob (Bob)", - "cath (Catherine)" - ] - cath ##> "/contacts" - cath - <### [ "alice (Alice)", - "bob (Bob)" - ] - alice `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - testMergeContactExistingMember :: HasCallStack => FilePath -> IO () testMergeContactExistingMember = testChat3 aliceProfile bobProfile cathProfile $ @@ -2889,66 +2816,6 @@ testMergeContactExistingMember = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactExistingMemberWithContact :: HasCallStack => FilePath -> IO () -testMergeContactExistingMemberWithContact = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - - -- create contact, delete only for bob so he would send probe hash to member - bob ##> "/_create member contact #1 3" - bob <## "contact for member #team cath is created" - - bob ##> "/_invite member contact @3 text hi" - bob - <### [ "sent invitation to connect directly to member #team cath", - WithTime "@cath hi" - ] - cath - <### [ "#team bob is creating direct contact bob with you", - WithTime "bob> hi" - ] - concurrently_ - (bob <## "cath (Catherine): contact is connected") - (cath <## "bob (Bob): contact is connected") - bob <##> cath - - bob ##> "/_delete @3 notify=off" - bob <## "cath: contact is deleted" - - bob ##> "/contacts" - bob <### ["alice (Alice)"] - cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - - -- contact connects, member is merged - bob ##> "/c" - inv' <- getInvitation bob - cath ##> ("/c " <> inv') - cath <## "confirmation sent!" - concurrentlyN_ - [ bob - <### [ "cath_1 (Catherine): contact is connected", - "contact and member are merged: cath_1, #team cath", - "use @cath to send messages" - ], - cath - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ] - ] - bob <##> cath - - bob ##> "/contacts" - bob <### ["alice (Alice)", "cath (Catherine)"] - cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - testMergeContactMultipleMembers :: HasCallStack => FilePath -> IO () testMergeContactMultipleMembers = testChat3 aliceProfile bobProfile cathProfile $ @@ -2987,70 +2854,11 @@ testMergeContactMultipleMembers = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactExistingMembersAndContacts :: HasCallStack => FilePath -> IO () -testMergeContactExistingMembersAndContacts = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - bob ##> "/contact_merge off" - bob <## "ok" - - create2Groups3 "team" "club" alice bob cath - - bob ##> "/c" - inv' <- getInvitation bob - cath ##> ("/c " <> inv') - cath <## "confirmation sent!" - concurrently_ - (bob <## "cath_2 (Catherine): contact is connected") - (cath <## "bob_2 (Bob): contact is connected") - - bob `hasContactProfiles` ["alice", "bob", "cath", "cath", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob", "bob", "bob"] - - bob ##> "/contact_merge on" - bob <## "ok" - - bob ##> "/c" - inv'' <- getInvitation bob - cath ##> ("/c " <> inv'') - cath <## "confirmation sent!" - concurrentlyN_ - [ bob - <### [ "cath_3 (Catherine): contact is connected", - StartsWith "contact and member are merged: cath", - StartsWith "use @cath", - StartsWith "contact and member are merged: cath", - StartsWith "use @cath", - StartsWith "contact cath_3 is merged into cath", - StartsWith "use @cath" - ], - cath - <### [ "bob_3 (Bob): contact is connected", - StartsWith "contact and member are merged: bob", - StartsWith "use @bob", - StartsWith "contact and member are merged: bob", - StartsWith "use @bob", - StartsWith "contact bob_3 is merged into bob", - StartsWith "use @bob" - ] - ] - bob <##> cath - - bob ##> "/contacts" - bob <### ["alice (Alice)", "cath (Catherine)"] - cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - -testMergeMemberContact :: HasCallStack => FilePath -> IO () -testMergeMemberContact = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - bob ##> "/contact_merge off" - bob <## "ok" - - createGroup3 "team" alice bob cath +testMergeGroupLinkHostMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeGroupLinkHostMultipleContacts = + testChat2 bobProfile cathProfile $ + \bob cath -> do + connectUsers bob cath bob ##> "/c" inv' <- getInvitation bob @@ -3060,61 +2868,40 @@ testMergeMemberContact = (bob <## "cath_1 (Catherine): contact is connected") (cath <## "bob_1 (Bob): contact is connected") - bob `hasContactProfiles` ["alice", "bob", "cath", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob", "bob"] + bob `hasContactProfiles` ["bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "bob", "bob"] - bob ##> "/contact_merge on" - bob <## "ok" - - -- bob and cath connect - bob ##> "/_create member contact #1 3" - bob <## "contact for member #team cath is created" - - bob ##> "/_invite member contact @4 text hi" - bob - <### [ "sent invitation to connect directly to member #team cath", - WithTime "@cath hi" - ] - cath - <### [ "#team bob is creating direct contact bob with you", - WithTime "bob> hi" - ] + bob ##> "/g party" + bob <## "group #party is created" + bob <## "to add members use /a party or /create link #party" + bob ##> "/create link #party" + gLink <- getGroupLink bob "party" GRMember True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + bob <## "cath_2 (Catherine): accepting request to join group #party..." concurrentlyN_ [ bob - <### [ "cath (Catherine): contact is connected", - "contact cath_1 is merged into cath", - -- StartsWith "use @cath" - "use @cath to send messages" + <### [ "cath_2 (Catherine): contact is connected", + EndsWith "invited to group #party via your group link", + EndsWith "joined the group", + StartsWith "contact cath_2 is merged into cath", + StartsWith "use @cath" ], cath - <### [ "bob (Bob): contact is connected", - "contact bob_1 is merged into bob", - -- StartsWith "use @bob" - "use @bob to send messages" + <### [ "bob_2 (Bob): contact is connected", + "#party: you joined the group", + StartsWith "contact bob_2 is merged into bob", + StartsWith "use @bob" ] ] bob <##> cath bob ##> "/contacts" - bob <### ["alice (Alice)", "cath (Catherine)"] + bob <### ["cath (Catherine)", "cath_1 (Catherine)"] cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - - -- group messages work - alice #> "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath <# "#team alice> hello") - bob #> "#team hi there" - concurrently_ - (alice <# "#team bob> hi there") - (cath <# "#team bob> hi there") - cath #> "#team hey team" - concurrently_ - (alice <# "#team cath> hey team") - (bob <# "#team cath> hey team") + cath <### ["bob (Bob)", "bob_1 (Bob)"] + bob `hasContactProfiles` ["bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "bob", "bob"] testMemberContactMessage :: HasCallStack => FilePath -> IO () testMemberContactMessage = diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index d7cf682560..da6cbd156f 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -20,7 +20,6 @@ chatProfileTests = do it "use multiword profile names" testMultiWordProfileNames describe "user contact link" $ do it "create and connect via contact link" testUserContactLink - it "merge existing contact when connecting via contact link" testUserContactLinkMerge it "add contact link to profile" testProfileLink it "auto accept contact requests" testUserContactLinkAutoAccept it "deduplicate contact requests" testDeduplicateContactRequests @@ -219,39 +218,6 @@ testUserContactLink = alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath -testUserContactLinkMerge :: HasCallStack => FilePath -> IO () -testUserContactLinkMerge = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - alice <##> bob - - alice ##> "/ad" - cLink <- getContactLink alice True - bob ##> ("/c " <> cLink) - bob <## "connection request sent!" - alice <## "bob_1 (Bob) wants to connect to you!" - alice <## "to accept: /ac bob_1" - alice <## "to reject: /rc bob_1 (the sender will NOT be notified)" - alice @@@ [("@bob", "hey"), ("<@bob_1", "")] - alice ##> "/ac bob_1" - alice <## "bob_1 (Bob): accepting contact request..." - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages" - ] - ] - threadDelay 100000 - alice @@@ [("@bob", lastChatFeature)] - alice <##> bob - testProfileLink :: HasCallStack => FilePath -> IO () testProfileLink = testChat3 aliceProfile bobProfile cathProfile $ From bc26c23d589d8129bad31fbbe5b19d1ce3c8b2f8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 Oct 2023 10:35:13 +0100 Subject: [PATCH 048/294] fix MobileTests (add single field JSON tag) --- tests/MobileTests.hs | 99 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 69c2207ff6..692dd5884e 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -65,71 +65,134 @@ mobileTests = do it "should convert invalid name to a valid name" testValidNameCApi noActiveUser :: LB.ByteString +noActiveUser = #if defined(darwin_HOST_OS) && defined(swiftJSON) -noActiveUser = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"noActiveUser\":{}}}}}}}" + noActiveUserSwift #else -noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" + noActiveUserTagged #endif +noActiveUserSwift :: LB.ByteString +noActiveUserSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}}}" + +noActiveUserTagged :: LB.ByteString +noActiveUserTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" + activeUserExists :: LB.ByteString +activeUserExists = #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}" + activeUserExistsSwift #else -activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" + activeUserExistsTagged #endif +activeUserExistsSwift :: LB.ByteString +activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}" + +activeUserExistsTagged :: LB.ByteString +activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" + activeUser :: LB.ByteString +activeUser = #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" + activeUserSwift #else -activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" + activeUserTagged #endif +activeUserSwift :: LB.ByteString +activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" + +activeUserTagged :: LB.ByteString +activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" + chatStarted :: LB.ByteString +chatStarted = #if defined(darwin_HOST_OS) && defined(swiftJSON) -chatStarted = "{\"resp\":{\"chatStarted\":{}}}" + chatStartedSwift #else -chatStarted = "{\"resp\":{\"type\":\"chatStarted\"}}" + chatStartedTagged #endif +chatStartedSwift :: LB.ByteString +chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" + +chatStartedTagged :: LB.ByteString +chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" + contactSubSummary :: LB.ByteString +contactSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -contactSubSummary = "{\"resp\":{\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" + contactSubSummarySwift #else -contactSubSummary = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" + contactSubSummaryTagged #endif +contactSubSummarySwift :: LB.ByteString +contactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" + +contactSubSummaryTagged :: LB.ByteString +contactSubSummaryTagged = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" + memberSubSummary :: LB.ByteString +memberSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -memberSubSummary = "{\"resp\":{\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" + memberSubSummarySwift #else -memberSubSummary = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" + memberSubSummaryTagged #endif +memberSubSummarySwift :: LB.ByteString +memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" + +memberSubSummaryTagged :: LB.ByteString +memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" + userContactSubSummary :: LB.ByteString +userContactSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -userContactSubSummary = "{\"resp\":{\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" + userContactSubSummarySwift #else -userContactSubSummary = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" + userContactSubSummaryTagged #endif +userContactSubSummarySwift :: LB.ByteString +userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" + +userContactSubSummaryTagged :: LB.ByteString +userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" + pendingSubSummary :: LB.ByteString +pendingSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" + pendingSubSummarySwift #else -pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" + pendingSubSummaryTagged #endif +pendingSubSummarySwift :: LB.ByteString +pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" + +pendingSubSummaryTagged :: LB.ByteString +pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" + userJSON :: LB.ByteString userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" parsedMarkdown :: LB.ByteString +parsedMarkdown = #if defined(darwin_HOST_OS) && defined(swiftJSON) -parsedMarkdown = "{\"formattedText\":[{\"format\":{\"bold\":{}},\"text\":\"hello\"}]}" + parsedMarkdownSwift #else -parsedMarkdown = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" + parsedMarkdownTagged #endif +parsedMarkdownSwift :: LB.ByteString +parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" + +parsedMarkdownTagged :: LB.ByteString +parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" + testChatApiNoUser :: FilePath -> IO () testChatApiNoUser tmp = do let dbPrefix = tmp "1" From 73b3ea36481c11e28332c734f43c2ddeacc9c231 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Mon, 9 Oct 2023 21:04:29 +0800 Subject: [PATCH 049/294] Drop entropy patch (#3191) * Drop entropy patch We don't need the patch anymore. We can set -fDoNotGetEntropy these days to achieve the same. * remove entropy.patch --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- flake.nix | 8 ++++---- scripts/nix/entropy.patch | 30 ------------------------------ 2 files changed, 4 insertions(+), 34 deletions(-) delete mode 100644 scripts/nix/entropy.patch diff --git a/flake.nix b/flake.nix index f6a68246e2..44a2e287ea 100644 --- a/flake.nix +++ b/flake.nix @@ -287,7 +287,7 @@ extra-modules = [{ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-swift-json" @@ -297,7 +297,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-tagged-json" @@ -310,7 +310,7 @@ extra-modules = [{ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-swift-json" @@ -320,7 +320,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-tagged-json" diff --git a/scripts/nix/entropy.patch b/scripts/nix/entropy.patch deleted file mode 100644 index 2add42acb3..0000000000 --- a/scripts/nix/entropy.patch +++ /dev/null @@ -1,30 +0,0 @@ -diff --git a/cbits/random_initialized.c b/cbits/random_initialized.c -index 36ac968..ab708b0 100644 ---- a/cbits/random_initialized.c -+++ b/cbits/random_initialized.c -@@ -5,14 +5,6 @@ - #include - #include - --#ifdef HAVE_GETENTROPY --static int ensure_pool_initialized_getentropy() --{ -- char tmp; -- return getentropy(&tmp, sizeof(tmp)); --} --#endif -- - // Poll /dev/random to wait for randomness. This is a proxy for the /dev/urandom - // pool being initialized. - static int ensure_pool_initialized_poll() -@@ -45,10 +37,5 @@ static int ensure_pool_initialized_poll() - // Returns 0 on success, non-zero on failure. - int ensure_pool_initialized() - { --#ifdef HAVE_GETENTROPY -- if (ensure_pool_initialized_getentropy() == 0) -- return 0; --#endif -- - return ensure_pool_initialized_poll(); - } From 09e5798d591cfddd90f9c98e70ccf0bd7a77633b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 Oct 2023 16:56:42 +0100 Subject: [PATCH 050/294] ios: correctly parse json responses (#3193) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- apps/ios/SimpleXChat/API.swift | 5 ++- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8b45e45f95..18021e3079 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -114,11 +114,11 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; - 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */; }; - 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739892AC9D168009470A9 /* libgmp.a */; }; - 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398A2AC9D168009470A9 /* libffi.a */; }; - 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398B2AC9D168009470A9 /* libgmpxx.a */; }; - 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */; }; + 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739922AD44E2E009470A9 /* libgmp.a */; }; + 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */; }; + 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739942AD44E2E009470A9 /* libffi.a */; }; + 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */; }; + 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739962AD44E2E009470A9 /* libgmpxx.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; @@ -395,11 +395,11 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; - 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a"; sourceTree = ""; }; - 5CC739892AC9D168009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CC7398A2AC9D168009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CC7398B2AC9D168009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a"; sourceTree = ""; }; + 5CC739922AD44E2E009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a"; sourceTree = ""; }; + 5CC739942AD44E2E009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a"; sourceTree = ""; }; + 5CC739962AD44E2E009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; @@ -507,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */, + 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */, - 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */, - 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */, - 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */, + 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */, + 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */, + 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +574,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CC7398A2AC9D168009470A9 /* libffi.a */, - 5CC739892AC9D168009470A9 /* libgmp.a */, - 5CC7398B2AC9D168009470A9 /* libgmpxx.a */, - 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */, - 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */, + 5CC739942AD44E2E009470A9 /* libffi.a */, + 5CC739922AD44E2E009470A9 /* libgmp.a */, + 5CC739962AD44E2E009470A9 /* libgmpxx.a */, + 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */, + 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index e3d202c124..0d59fe55c7 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -132,8 +132,11 @@ public func chatResponse(_ s: String) -> ChatResponse { var type: String? var json: String? if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 { + if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { type = jResp.allKeys[0] as? String + if jResp.count == 2 && type == "_owsf" { + type = jResp.allKeys[1] as? String + } if type == "apiChats" { if let jApiChats = jResp["apiChats"] as? NSDictionary, let user: UserRef = try? decodeObject(jApiChats["user"] as Any), From d764b3485ab7efaf24e7a0bc4a0481d1c33bc9d4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 10 Oct 2023 00:10:47 +0800 Subject: [PATCH 051/294] desktop (windows): Github action for packaging (#3167) * desktop (windows): Github action for packaging * env * path changes --- .github/workflows/build.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c7a55b148..8785360693 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -293,4 +293,37 @@ jobs: body: | ${{ steps.windows_build.outputs.bin_hash }} + - name: Windows build desktop + id: windows_desktop_build + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + env: + SIMPLEX_CI_REPO_URL: ${{ secrets.SIMPLEX_CI_REPO_URL }} + shell: bash + run: | + scripts/desktop/build-lib-windows.sh + cd apps/multiplatform + ./gradlew packageMsi + path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') + echo "package_path=$path" >> $GITHUB_OUTPUT + echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + - name: Windows upload desktop package to release + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ steps.windows_desktop_build.outputs.package_path }} + asset_name: ${{ matrix.desktop_asset_name }} + tag: ${{ github.ref }} + + - name: Windows update desktop package hash + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + append_body: true + body: | + ${{ steps.windows_desktop_build.outputs.package_hash }} + # Windows / From c0e22d74c4df1e2c968f43c63f8d5efd7aa530bd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 Oct 2023 17:30:48 +0100 Subject: [PATCH 052/294] core: 5.4.0.1 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 907a2a0686..6fed41b2ae 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.0 +version: 5.4.0.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 30c4c62dc1..4148f0ba83 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.0 +version: 5.4.0.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From a67b79952b467bb543ace2122feb46230ccbedcd Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 10 Oct 2023 21:19:04 +0400 Subject: [PATCH 053/294] core: connection plan api; check connection plan before connecting in terminal api (#3176) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 112 +++++++-- src/Simplex/Chat/Controller.hs | 61 +++++ .../M20231009_via_group_link_uri_hash.hs | 24 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 7 +- src/Simplex/Chat/Store/Connections.hs | 9 +- src/Simplex/Chat/Store/Direct.hs | 48 ++-- src/Simplex/Chat/Store/Groups.hs | 43 ++++ src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 15 +- src/Simplex/Chat/Types.hs | 6 + src/Simplex/Chat/View.hs | 37 +++ tests/ChatTests/Direct.hs | 67 +++++ tests/ChatTests/Groups.hs | 237 ++++++++++++++++++ tests/ChatTests/Profiles.hs | 165 +++++++++++- 15 files changed, 784 insertions(+), 52 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4148f0ba83..5a84a1cde8 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -115,6 +115,7 @@ library Simplex.Chat.Migrations.M20230914_member_probes Simplex.Chat.Migrations.M20230926_contact_status Simplex.Chat.Migrations.M20231002_conn_initiated + Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 296abf0e2b..6f43f5c0f8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -902,7 +902,7 @@ processChatCommand = \case filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withChatLock "deleteChat direct" . procCmd $ do deleteFilesAndConns user filesInfo - when (isReady ct && contactActive ct && notify) $ + when (contactReady ct && contactActive ct && notify) $ void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) deleteAgentConnectionsAsync user contactConnIds @@ -1311,6 +1311,8 @@ processChatCommand = \case case conn'_ of Just conn' -> pure $ CRConnectionIncognitoUpdated user conn' Nothing -> throwChatError CEConnectionIncognitoChangeProhibited + APIConnectPlan userId cReqUri -> withUserId userId $ \user -> withChatLock "connectPlan" . procCmd $ + CRConnectionPlan user <$> connectPlan user cReqUri APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send @@ -1323,11 +1325,16 @@ processChatCommand = \case pure $ CRSentConfirmation user APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq - Connect incognito cReqUri -> withUser $ \User {userId} -> - processChatCommand $ APIConnect userId incognito cReqUri - ConnectSimplex incognito -> withUser $ \user -> - -- [incognito] generate profile to send - connectViaContact user incognito adminContactReq + Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do + plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) + unless (connectionPlanOk plan) $ throwChatError (CEConnectionPlan plan) + processChatCommand $ APIConnect userId incognito aCReqUri + Connect _ Nothing -> throwChatError CEInvalidConnReq + ConnectSimplex incognito -> withUser $ \user@User {userId} -> do + let cReqUri = ACR SCMContact adminContactReq + plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) + unless (connectionPlanOk plan) $ throwChatError (CEConnectionPlan plan) + processChatCommand $ APIConnect userId incognito (Just cReqUri) DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> @@ -1423,7 +1430,7 @@ processChatCommand = \case processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore' (`getUserContacts` user) - let cts = filter (\ct -> isReady ct && contactActive ct && directOrUsed ct) contacts + let cts = filter (\ct -> contactReady ct && contactActive ct && directOrUsed ct) contacts ChatConfig {logLevel} <- asks config withChatLock "sendMessageBroadcast" . procCmd $ do (successes, failures) <- foldM (sendAndCount user logLevel) (0, 0) cts @@ -1924,19 +1931,36 @@ processChatCommand = \case _ -> throwChatError $ CECommandError "not supported" connectViaContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> m ChatResponse connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do - let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case - (Just contact, _) -> pure $ CRContactAlreadyExists user contact - (_, xContactId_) -> procCmd $ do - let randomXContactId = XContactId <$> drgRandomBytes 16 - xContactId <- maybe randomXContactId pure xContactId_ - subMode <- chatReadVar subscriptionMode + let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq + case groupLinkId of + -- contact address + Nothing -> + withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + (Just contact, _) -> pure $ CRContactAlreadyExists user contact + (_, xContactId_) -> procCmd $ do + let randomXContactId = XContactId <$> drgRandomBytes 16 + xContactId <- maybe randomXContactId pure xContactId_ + connect' Nothing cReqHash xContactId + -- group link + Just gLinkId -> + withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + (Just _contact, _) -> procCmd $ do + -- allow repeat contact request + newXContactId <- XContactId <$> drgRandomBytes 16 + connect' (Just gLinkId) cReqHash newXContactId + (_, xContactId_) -> procCmd $ do + let randomXContactId = XContactId <$> drgRandomBytes 16 + xContactId <- maybe randomXContactId pure xContactId_ + connect' (Just gLinkId) cReqHash xContactId + where + connect' groupLinkId cReqHash xContactId = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing dm <- directMessage (XContact profileToSend $ Just xContactId) + subMode <- chatReadVar subscriptionMode connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode - let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode toView $ CRNewContactConnection user conn pure $ CRSentInvitation user incognitoProfile @@ -1975,7 +1999,7 @@ processChatCommand = \case -- read contacts before user update to correctly merge preferences -- [incognito] filter out contacts with whom user has incognito connections contacts <- - filter (\ct -> isReady ct && contactActive ct && not (contactConnIncognito ct)) + filter (\ct -> contactReady ct && contactActive ct && not (contactConnIncognito ct)) <$> withStore' (`getUserContacts` user) user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') @@ -2046,10 +2070,6 @@ processChatCommand = \case g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> getGroupIdByName db user gName >>= getGroup db user runUpdateGroupProfile user g $ update p - isReady :: Contact -> Bool - isReady ct = - let s = connStatus $ ct.activeConn - in s == ConnReady || s == ConnSndReady withCurrentCall :: ContactId -> (User -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do @@ -2168,6 +2188,54 @@ processChatCommand = \case pure (gId, chatSettings) _ -> throwChatError $ CECommandError "not supported" processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ updateSettings chatSettings + connectPlan :: User -> AConnectionRequestUri -> m ConnectionPlan + connectPlan user (ACR SCMInvitation cReq) = do + withStore' (\db -> getConnectionEntityByConnReq db user cReq) >>= \case + Nothing -> pure $ CPInvitationLink ILPOk + Just (RcvDirectMsgConnection conn ct_) -> do + let Connection {connStatus, contactConnInitiated} = conn + if + | connStatus == ConnNew && contactConnInitiated -> + pure $ CPInvitationLink ILPOwnLink + | not (connReady conn) -> + pure $ CPInvitationLink (ILPConnecting ct_) + | otherwise -> case ct_ of + Just ct -> pure $ CPInvitationLink (ILPKnown ct) + Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" + Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + connectPlan user (ACR SCMContact cReq) = do + let CRContactUri ConnReqUriData {crClientData} = cReq + groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + case groupLinkId of + -- contact address + Nothing -> + withStore' (`getUserContactLinkByConnReq` cReq) >>= \case + Just _ -> pure $ CPContactAddress CAPOwnLink + Nothing -> do + let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq + withStore' (\db -> getContactByConnReqHash db user cReqHash) >>= \case + Nothing -> pure $ CPContactAddress CAPOk + Just ct + | not (contactReady ct) && contactActive ct -> pure $ CPContactAddress (CAPConnecting ct) + | otherwise -> pure $ CPContactAddress (CAPKnown ct) + -- group link + Just _ -> + withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReq) >>= \case + Just g -> pure $ CPGroupLink (GLPOwnLink g) + Nothing -> do + let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq + ct_ <- withStore' $ \db -> getContactByConnReqHash db user cReqHash + gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHash + case (gInfo_, ct_) of + (Nothing, Nothing) -> pure $ CPGroupLink GLPOk + (Nothing, Just ct) + | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnecting gInfo_) + | otherwise -> pure $ CPGroupLink GLPOk + (Just gInfo@GroupInfo {membership}, _) + | not (memberActive membership) && not (memberRemoved membership) -> + pure $ CPGroupLink (GLPConnecting gInfo_) + | memberActive membership -> pure $ CPGroupLink (GLPKnown gInfo) + | otherwise -> pure $ CPGroupLink GLPOk assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m () assertDirectAllowed user dir ct event = @@ -4230,7 +4298,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () processGroupInvitation ct inv msg msgMeta = do - let Contact {localDisplayName = c, activeConn = Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct + let Contact {localDisplayName = c, activeConn = Connection {connId, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv checkIntegrityCreateItem (CDDirectRcv ct) msgMeta when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) @@ -4243,6 +4311,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do dm <- directMessage $ XGrpAcpt memberId connIds <- joinAgentConnectionAsync user True connRequest dm subMode withStore' $ \db -> do + setViaGroupLinkHash db groupId connId createMemberConnectionAsync db user hostId connIds (fromJVersionRange peerChatVRange) subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted @@ -5642,6 +5711,7 @@ chatCommandP = (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP), "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, + "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> strP), "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), "/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP), "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d859231faa..3466371da8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -338,6 +338,7 @@ data ChatCommand | APIAddContact UserId IncognitoEnabled | AddContact IncognitoEnabled | APISetConnectionIncognito Int64 IncognitoEnabled + | APIConnectPlan UserId AConnectionRequestUri | APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri) | Connect IncognitoEnabled (Maybe AConnectionRequestUri) | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) @@ -489,6 +490,7 @@ data ChatResponse | CRVersionInfo {versionInfo :: CoreVersionInfo, chatMigrations :: [UpMigration], agentMigrations :: [UpMigration]} | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} + | CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan} | CRSentConfirmation {user :: User} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} @@ -624,6 +626,64 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +data ConnectionPlan + = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} + | CPContactAddress {contactAddressPlan :: ContactAddressPlan} + | CPGroupLink {groupLinkPlan :: GroupLinkPlan} + deriving (Show, Generic) + +instance ToJSON ConnectionPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CP" + +data InvitationLinkPlan + = ILPOk + | ILPOwnLink + | ILPConnecting {contact_ :: Maybe Contact} + | ILPKnown {contact :: Contact} + deriving (Show, Generic) + +instance ToJSON InvitationLinkPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "ILP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "ILP" + +data ContactAddressPlan + = CAPOk + | CAPOwnLink + | CAPConnecting {contact :: Contact} + | CAPKnown {contact :: Contact} + deriving (Show, Generic) + +instance ToJSON ContactAddressPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CAP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CAP" + +data GroupLinkPlan + = GLPOk + | GLPOwnLink {groupInfo :: GroupInfo} + | GLPConnecting {groupInfo_ :: Maybe GroupInfo} + | GLPKnown {groupInfo :: GroupInfo} + deriving (Show, Generic) + +instance ToJSON GroupLinkPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "GLP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "GLP" + +connectionPlanOk :: ConnectionPlan -> Bool +connectionPlanOk = \case + CPInvitationLink ilp -> case ilp of + ILPOk -> True + ILPOwnLink -> True + _ -> False + CPContactAddress cap -> case cap of + CAPOk -> True + CAPOwnLink -> True + _ -> False + CPGroupLink glp -> case glp of + GLPOk -> True + GLPOwnLink _ -> True + _ -> False + newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -888,6 +948,7 @@ data ChatErrorType | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged + | CEConnectionPlan {connectionPlan :: ConnectionPlan} | CEInvalidConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} diff --git a/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs b/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs new file mode 100644 index 0000000000..41c9887a04 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs @@ -0,0 +1,24 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231009_via_group_link_uri_hash :: Query +m20231009_via_group_link_uri_hash = + [sql| +CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); + +ALTER TABLE groups ADD COLUMN via_group_link_uri_hash BLOB; +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups(via_group_link_uri_hash); +|] + +down_m20231009_via_group_link_uri_hash :: Query +down_m20231009_via_group_link_uri_hash = + [sql| +DROP INDEX idx_groups_via_group_link_uri_hash; +ALTER TABLE groups DROP COLUMN via_group_link_uri_hash; + +DROP INDEX idx_connections_conn_req_inv; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index e88d83e42d..542acbbebd 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -117,7 +117,8 @@ CREATE TABLE groups( unread_chat INTEGER DEFAULT 0 CHECK(unread_chat NOT NULL), chat_ts TEXT, favorite INTEGER NOT NULL DEFAULT 0, - send_rcpts INTEGER, -- received + send_rcpts INTEGER, + via_group_link_uri_hash BLOB, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -736,3 +737,7 @@ CREATE INDEX idx_received_probes_probe_hash ON received_probes(probe_hash); CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); +CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups( + via_group_link_uri_hash +); diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 383db3c59c..c9e846a81f 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Store.Connections ( getConnectionEntity, + getConnectionEntityByConnReq, getConnectionsToSubscribe, unsetConnectionToSubscribe, ) @@ -31,7 +32,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow') +import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Util (eitherToMaybe) @@ -152,6 +153,12 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound +getConnectionEntityByConnReq :: DB.Connection -> User -> ConnReqInvitation -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq db user cReq = do + connId_ <- maybeFirstRow fromOnly $ + DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv = ? LIMIT 1" (Only cReq) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ + getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) getConnectionsToSubscribe db = do aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 91243e2319..7227797193 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -25,6 +25,7 @@ module Simplex.Chat.Store.Direct createConnReqConnection, getProfileById, getConnReqContactXContactId, + getContactByConnReqHash, createDirectContact, deleteContactConnectionsAndFiles, deleteContact, @@ -137,32 +138,10 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou getConnReqContactXContactId :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) getConnReqContactXContactId db user@User {userId} cReqHash = do - getContact' >>= \case + getContactByConnReqHash db user cReqHash >>= \case c@(Just _) -> pure (c, Nothing) Nothing -> (Nothing,) <$> getXContactId where - getContact' :: IO (Maybe Contact) - getContact' = - maybeFirstRow (toContact user) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 - |] - (userId, cReqHash) getXContactId :: IO (Maybe XContactId) getXContactId = maybeFirstRow fromOnly $ @@ -171,6 +150,29 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) +getContactByConnReqHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact) +getContactByConnReqHash db user@User {userId} cReqHash = + maybeFirstRow (toContact user) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + |] + (userId, cReqHash, CSActive) + createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> IO PendingContactConnection createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do createdAt <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 236031da94..20fb8c7217 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -31,9 +31,12 @@ module Simplex.Chat.Store.Groups getGroupAndMember, createNewGroup, createGroupInvitation, + setViaGroupLinkHash, setGroupInvitationChatItemId, getGroup, getGroupInfo, + getGroupInfoByUserContactLinkConnReq, + getGroupInfoByGroupLinkHash, updateGroupProfile, getGroupIdByName, getGroupMemberIdByName, @@ -405,6 +408,17 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me ) pure $ Right incognitoLdn +setViaGroupLinkHash :: DB.Connection -> GroupId -> Int64 -> IO () +setViaGroupLinkHash db groupId connId = + DB.execute + db + [sql| + UPDATE groups + SET via_group_link_uri_hash = (SELECT via_contact_uri_hash FROM connections WHERE connection_id = ?) + WHERE group_id = ? + |] + (connId, groupId) + setGroupInvitationChatItemId :: DB.Connection -> User -> GroupId -> ChatItemId -> IO () setGroupInvitationChatItemId db User {userId} groupId chatItemId = do currentTs <- getCurrentTime @@ -1102,6 +1116,35 @@ getGroupInfo db User {userId, userContactId} groupId = |] (groupId, userId, userContactId) +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> ConnReqContact -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq db user cReq = do + groupId_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT group_id + FROM user_contact_links + WHERE conn_req_contact = ? + |] + (Only cReq) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ + +getGroupInfoByGroupLinkHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash db user@User {userId, userContactId} groupLinkHash = do + groupId_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? AND g.via_group_link_uri_hash = ? + AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) + LIMIT 1 + |] + (userId, groupLinkHash, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ + getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId getGroupIdByName db User {userId} gName = ExceptT . firstRow fromOnly (SEGroupNotFoundByName gName) $ diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 3ef68874b0..5c44b8cded 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -83,6 +83,7 @@ import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes import Simplex.Chat.Migrations.M20230926_contact_status import Simplex.Chat.Migrations.M20231002_conn_initiated +import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -165,7 +166,8 @@ schemaMigrations = ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), - ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated) + ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated), + ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index a577796810..5b5a6eb671 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -42,6 +42,7 @@ module Simplex.Chat.Store.Profiles deleteUserAddress, getUserAddress, getUserContactLinkById, + getUserContactLinkByConnReq, updateUserAddressAutoAccept, getProtocolServers, overwriteProtocolServers, @@ -86,7 +87,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (safeDecodeUtf8) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime @@ -440,6 +441,18 @@ getUserContactLinkById db userId userContactLinkId = |] (userId, userContactLinkId) +getUserContactLinkByConnReq :: DB.Connection -> ConnReqContact -> IO (Maybe UserContactLink) +getUserContactLinkByConnReq db cReq = + maybeFirstRow toUserContactLink $ + DB.query + db + [sql| + SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE conn_req_contact = ? + |] + (Only cReq) + updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept db user@User {userId} autoAccept = do link <- getUserAddress db user diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 529d2bf019..864ebd7227 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -206,6 +206,9 @@ directOrUsed ct@Contact {contactUsed} = anyDirectOrUsed :: Contact -> Bool anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed +contactReady :: Contact -> Bool +contactReady Contact {activeConn} = connReady activeConn + contactActive :: Contact -> Bool contactActive Contact {contactStatus} = contactStatus == CSActive @@ -1244,6 +1247,9 @@ data Connection = Connection } deriving (Eq, Show, Generic) +connReady :: Connection -> Bool +connReady Connection {connStatus} = connStatus == ConnReady || connStatus == ConnSndReady + authErrDisableCount :: Int authErrDisableCount = 10 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b981929efc..bb5e854cd2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -148,6 +148,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRVersionInfo info _ _ -> viewVersionInfo logLevel info CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c + CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] @@ -1223,6 +1224,41 @@ viewConnectionIncognitoUpdated PendingContactConnection {pccConnId, customUserPr | isJust customUserProfileId = ["connection " <> sShow pccConnId <> " changed to incognito"] | otherwise = ["connection " <> sShow pccConnId <> " changed to non incognito"] +viewConnectionPlan :: ConnectionPlan -> [StyledString] +viewConnectionPlan = \case + CPInvitationLink ilp -> case ilp of + ILPOk -> [invLink "ok to connect"] + ILPOwnLink -> [invLink "own link"] + ILPConnecting Nothing -> [invLink "connecting"] + ILPConnecting (Just ct) -> [invLink ("connecting to contact " <> ttyContact' ct)] + ILPKnown ct -> + [ invLink ("known contact " <> ttyContact' ct), + "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" + ] + where + invLink = ("invitation link: " <>) + CPContactAddress cap -> case cap of + CAPOk -> [ctAddr "ok to connect"] + CAPOwnLink -> [ctAddr "own address"] + CAPConnecting ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] + CAPKnown ct -> + [ ctAddr ("known contact " <> ttyContact' ct), + "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" + ] + where + ctAddr = ("contact address: " <>) + CPGroupLink glp -> case glp of + GLPOk -> [grpLink "ok to connect"] + GLPOwnLink g -> [grpLink "own link for group " <> ttyGroup' g] + GLPConnecting Nothing -> [grpLink "connecting"] + GLPConnecting (Just g) -> [grpLink ("connecting to group " <> ttyGroup' g)] + GLPKnown g -> + [ grpLink ("known group " <> ttyGroup' g), + "use " <> ttyToGroup g <> highlight' "" <> " to send messages" + ] + where + grpLink = ("group link: " <>) + viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated Contact {localDisplayName = n, profile = LocalProfile {fullName, contactLink}} @@ -1565,6 +1601,7 @@ viewChatError logLevel = \case CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] + CEConnectionPlan connectionPlan -> viewConnectionPlan connectionPlan CEInvalidConnReq -> viewInvalidConnReq CEInvalidChatMessage Connection {connId} msgMeta_ msg e -> [ plain $ diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index a5fc7455c8..47333906bb 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -44,6 +44,10 @@ chatDirectTests = do describe "duplicate contacts" $ do it "duplicate contacts are separate (contacts don't merge)" testDuplicateContactsSeparate it "new contact is separate with multiple duplicate contacts (contacts don't merge)" testDuplicateContactsMultipleSeparate + describe "invitation link connection plan" $ do + it "invitation link ok to connect" testPlanInvitationLinkOk + it "own invitation link" testPlanInvitationLinkOwn + it "connecting via invitation link" testPlanInvitationLinkConnecting describe "SMP servers" $ do it "get and set SMP servers" testGetSetSMPServers it "test SMP server connection" testTestSMPServerConnection @@ -236,6 +240,69 @@ testDuplicateContactsMultipleSeparate = alice `hasContactProfiles` ["alice", "bob", "bob", "bob"] bob `hasContactProfiles` ["bob", "alice", "alice", "alice"] +testPlanInvitationLinkOk :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkOk = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" + + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection + + alice <##> bob + +testPlanInvitationLinkOwn :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkOwn tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/c" + inv <- getInvitation alice + alice ##> ("/_connect plan 1 " <> inv) + alice <## "invitation link: own link" + + alice ##> ("/c " <> inv) + alice <## "confirmation sent!" + alice + <### [ "alice_1 (Alice): contact is connected", + "alice_2 (Alice): contact is connected" + ] + + alice ##> ("/_connect plan 1 " <> inv) + alice <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection + + alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] + alice `send` "@alice_2 hi" + alice + <### [ WithTime "@alice_2 hi", + WithTime "alice_1> hi" + ] + alice `send` "@alice_1 hey" + alice + <### [ WithTime "@alice_1 hey", + WithTime "alice_2> hey" + ] + alice @@@ [("@alice_1", "hey"), ("@alice_2", "hey")] + +testPlanInvitationLinkConnecting :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkConnecting tmp = do + inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: connecting" + testContactClear :: HasCallStack => FilePath -> IO () testContactClear = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 9fb6ac7f9b..997beec4ea 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -57,6 +57,12 @@ chatGroupTests = do it "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted it "group link member role" testGroupLinkMemberRole it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete + describe "group link connection plan" $ do + it "group link ok to connect; known group" testPlanGroupLinkOkKnown + it "group is known if host contact was deleted" testPlanHostContactDeletedGroupLinkKnown + it "own group link" testPlanGroupLinkOwn + it "connecting via group link" testPlanGroupLinkConnecting + it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin describe "group message errors" $ do it "show message decryption error" testGroupMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testGroupSyncRatchet @@ -2251,6 +2257,237 @@ testGroupLinkLeaveDelete = bob <## "alice (Alice)" bob <## "cath (Catherine)" +testPlanGroupLinkOkKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkOkKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect" + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "bob (Bob): contact is connected" + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", + do + bob <## "alice (Alice): contact is connected" + bob <## "#team: you joined the group" + ] + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + +testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => FilePath -> IO () +testPlanHostContactDeletedGroupLinkKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "bob (Bob): contact is connected" + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", + do + bob <## "alice (Alice): contact is connected" + bob <## "#team: you joined the group" + ] + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + + alice <##> bob + threadDelay 500000 + bob ##> "/d alice" + bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + +testPlanGroupLinkOwn :: HasCallStack => FilePath -> IO () +testPlanGroupLinkOwn tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + alice ##> ("/_connect plan 1 " <> gLink) + alice <## "group link: own link for group #team" + + alice ##> ("/c " <> gLink) + alice <## "connection request sent!" + alice <## "alice_1 (Alice): accepting request to join group #team..." + alice + <### [ "alice_1 (Alice): contact is connected", + "alice_1 invited to group #team via your group link", + "#team: alice_1 joined the group", + "alice_2 (Alice): contact is connected", + "#team_1: you joined the group", + "contact alice_2 is merged into alice_1", + "use @alice_1 to send messages" + ] + alice `send` "#team 1" + alice + <### [ WithTime "#team 1", + WithTime "#team_1 alice_1> 1" + ] + alice `send` "#team_1 2" + alice + <### [ WithTime "#team_1 2", + WithTime "#team alice_1> 2" + ] + + alice ##> ("/_connect plan 1 " <> gLink) + alice <## "group link: own link for group #team" + + -- group works if merged contact is deleted + alice ##> "/d alice_1" + alice <## "alice_1: contact is deleted" + + alice `send` "#team 3" + alice + <### [ WithTime "#team 3", + WithTime "#team_1 alice_1> 3" + ] + alice `send` "#team_1 4" + alice + <### [ WithTime "#team_1 4", + WithTime "#team alice_1> 4" + ] + +testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO () +testPlanGroupLinkConnecting tmp = do + gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + getGroupLink alice "team" GRMember True + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + withTestChat tmp "alice" $ \alice -> do + alice + <### [ "1 group links active", + "#team: group is empty", + "bob (Bob): accepting request to join group #team..." + ] + withTestChat tmp "bob" $ \bob -> do + threadDelay 500000 + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting" + + bob ##> ("/c " <> gLink) + bob <## "group link: connecting" + +testPlanGroupLinkLeaveRejoin :: HasCallStack => FilePath -> IO () +testPlanGroupLinkLeaveRejoin = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "bob (Bob): contact is connected" + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", + do + bob <## "alice (Alice): contact is connected" + bob <## "#team: you joined the group" + ] + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> "/leave #team" + concurrentlyN_ + [ do + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group", + alice <## "#team: bob left the group" + ] + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect" + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice + <### [ "bob_1 (Bob): contact is connected", + "bob_1 invited to group #team via your group link", + EndsWith "joined the group", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + bob + <### [ "alice_1 (Alice): contact is connected", + "#team_1: you joined the group", + "contact alice_1 is merged into alice", + "use @alice to send messages" + ] + ] + + alice #> "#team hi" + bob <# "#team_1 alice> hi" + bob #> "#team_1 hey" + alice <# "#team bob> hey" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team_1" + bob <## "use #team_1 to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team_1" + bob <## "use #team_1 to send messages" + testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () testGroupMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index da6cbd156f..0d7683c4d7 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -28,6 +28,11 @@ chatProfileTests = do it "delete connection requests when contact link deleted" testDeleteConnectionRequests it "auto-reply message" testAutoReplyMessage it "auto-reply message in incognito" testAutoReplyMessageInIncognito + describe "contact address connection plan" $ do + it "contact address ok to connect; known contact" testPlanAddressOkKnown + it "own contact address" testPlanAddressOwn + it "connecting via contact address" testPlanAddressConnecting + it "re-connect with deleted contact" testPlanAddressContactDeletedReconnected describe "incognito" $ do it "connect incognito via invitation link" testConnectIncognitoInvitationLink it "connect incognito via contact address" testConnectIncognitoContactAddress @@ -369,7 +374,8 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ (alice <## "bob (Bob): contact is connected") bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice @@@ [("@bob", lastChatFeature)] bob @@@ [("@alice", lastChatFeature), (":2", ""), (":1", "")] bob ##> "/_delete :1" @@ -382,7 +388,8 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice <##> bob alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")]) @@ -440,7 +447,8 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile (alice <## "robert (Robert): contact is connected") bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice @@@ [("@robert", lastChatFeature)] bob @@@ [("@alice", lastChatFeature), (":3", ""), (":2", ""), (":1", "")] bob ##> "/_delete :1" @@ -455,7 +463,8 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice <##> bob alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")]) @@ -566,6 +575,154 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ ] ] +testPlanAddressOkKnown :: HasCallStack => FilePath -> IO () +testPlanAddressOkKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: ok to connect" + + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + bob ##> ("/c " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + +testPlanAddressOwn :: HasCallStack => FilePath -> IO () +testPlanAddressOwn tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + alice ##> ("/_connect plan 1 " <> cLink) + alice <## "contact address: own address" + + alice ##> ("/c " <> cLink) + alice <## "connection request sent!" + alice <## "alice_1 (Alice) wants to connect to you!" + alice <## "to accept: /ac alice_1" + alice <## ("to reject: /rc alice_1 (the sender will NOT be notified)") + alice @@@ [("<@alice_1", ""), (":2","")] + alice ##> "/ac alice_1" + alice <## "alice_1 (Alice): accepting contact request..." + alice + <### [ "alice_1 (Alice): contact is connected", + "alice_2 (Alice): contact is connected" + ] + + alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] + alice `send` "@alice_2 hi" + alice + <### [ WithTime "@alice_2 hi", + WithTime "alice_1> hi" + ] + alice `send` "@alice_1 hey" + alice + <### [ WithTime "@alice_1 hey", + WithTime "alice_2> hey" + ] + alice @@@ [("@alice_1", "hey"), ("@alice_2", "hey")] + + alice ##> ("/_connect plan 1 " <> cLink) + alice <## "contact address: own address" + + alice ##> ("/c " <> cLink) + alice <## "alice_2 (Alice): contact already exists" + +testPlanAddressConnecting :: HasCallStack => FilePath -> IO () +testPlanAddressConnecting tmp = do + cLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/ad" + getContactLink alice True + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + withTestChat tmp "alice" $ \alice -> do + alice <## "Your address is active! To show: /sa" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + withTestChat tmp "bob" $ \bob -> do + threadDelay 500000 + bob @@@ [("@alice", "")] + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: connecting to contact alice" + + bob ##> ("/c " <> cLink) + bob <## "contact address: connecting to contact alice" + +testPlanAddressContactDeletedReconnected :: HasCallStack => FilePath -> IO () +testPlanAddressContactDeletedReconnected = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + bob ##> ("/c " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + alice ##> "/d bob" + alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: ok to connect" + + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice_1 (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + alice #> "@bob hi" + bob <# "alice_1> hi" + bob #> "@alice_1 hey" + alice <# "bob> hey" + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice_1" + bob <## "use @alice_1 to send messages" + + bob ##> ("/c " <> cLink) + bob <## "contact address: known contact alice_1" + bob <## "use @alice_1 to send messages" + testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO () testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do From 6f5ba54f7b96e85a6b59d0522e4d6b71754e2f48 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 11 Oct 2023 11:45:05 +0300 Subject: [PATCH 054/294] core: remote session files (#3189) * Receiving files on CRRcvFileComplete * Add remote /fr test * Add broken startFileTransfer notice * Sending files with SendFile/SendImage With tests for SendFile. * Add APISendMessage handling * Test file preconditions No files should be in stores before actual sending. * Fix mobile paths in storeFile --- src/Simplex/Chat/Remote.hs | 218 +++++++++++++++++++++++++++---------- tests/RemoteTests.hs | 92 ++++++++++++++++ 2 files changed, 255 insertions(+), 55 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 7222743350..0f03c1fdb5 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} @@ -16,36 +17,47 @@ import Control.Monad.Reader (asks) import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J +import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Binary.Builder as Binary -import Data.ByteString (ByteString) +import Data.ByteString (ByteString, hPut) import qualified Data.ByteString.Base64.URL as B64U import qualified Data.ByteString.Char8 as B +import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty (..)) -import Data.Maybe (fromMaybe) import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) import qualified Data.Text as T +import Data.Text.Encoding (decodeUtf8, encodeUtf8) import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP.Types.Status as Status import qualified Network.HTTP2.Client as HTTP2Client import qualified Network.HTTP2.Server as HTTP2Server import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller +import Simplex.Chat.Messages (AChatItem (..), CIFile (..), CIFileStatus (..), ChatItem (..), chatNameStr) +import Simplex.Chat.Messages.CIContent (MsgDirection (..), SMsgDirection (..)) import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Files (getRcvFileTransfer) +import Simplex.Chat.Store.Profiles (getUser) import Simplex.Chat.Store.Remote +import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Types +import Simplex.FileTransfer.Util (uniqueCombine) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) -import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..)) +import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), defaultHTTP2BufferSize) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 import Simplex.Messaging.Util (bshow, ifM, tshow) -import System.Directory (getFileSize) +import System.FilePath (isPathSeparator, takeFileName, ()) import UnliftIO +import UnliftIO.Directory (createDirectoryIfMissing, getFileSize, makeAbsolute) withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a withRemoteHostSession remoteHostId action = do @@ -90,7 +102,15 @@ startRemoteHost remoteHostId = do cleanup finished Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do logDebug $ "Got initial from remote host: " <> tshow bodyHead - _ <- asks outputQ >>= async . pollRemote finished ctrlClient "/recv" (Nothing, Just remoteHostId,) + oq <- asks outputQ + let toViewRemote = atomically . writeTBQueue oq . (Nothing,Just remoteHostId,) + void . async $ pollRemote finished ctrlClient "/recv" $ \chatResponse -> do + case chatResponse of + CRRcvFileComplete {user = ru, chatItem = AChatItem c d@SMDRcv i ci@ChatItem {file = Just ciFile}} -> do + handleRcvFileComplete ctrlClient storePath ru ciFile >>= \case + Nothing -> toViewRemote chatResponse + Just localFile -> toViewRemote CRRcvFileComplete {user = ru, chatItem = AChatItem c d i ci {file = Just localFile}} + _ -> toViewRemote chatResponse toView CRRemoteHostConnected {remoteHostId} sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response) @@ -98,16 +118,17 @@ sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing) where req = HTTP2Client.requestNoBody "GET" "/" mempty -pollRemote :: (ChatMonad m, J.FromJSON a) => TVar Bool -> HTTP2Client -> ByteString -> (a -> b) -> TBQueue b -> m () -pollRemote finished http path f queue = loop +pollRemote :: (ChatMonad m, J.FromJSON a) => TVar Bool -> HTTP2Client -> ByteString -> (a -> m ()) -> m () +pollRemote finished http path action = loop where loop = do liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case Left e -> logError $ "pollRemote: " <> tshow (path, e) - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> + Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do + logDebug $ "Got /recv response: " <> decodeUtf8 bodyHead case J.eitherDecodeStrict' bodyHead of Left e -> logError $ "pollRemote/decode: " <> tshow (path, e) - Right o -> atomically $ writeTBQueue queue (f o) + Right o -> action o readTVarIO finished >>= (`unless` loop) req = HTTP2Client.requestNoBody "GET" path mempty @@ -118,7 +139,7 @@ closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \sess chatModifyVar remoteHostSessions $ M.delete remoteHostId pure CRRemoteHostStopped {remoteHostId} -cancelRemoteHostSession :: MonadUnliftIO m => RemoteHostSession -> m () +cancelRemoteHostSession :: (MonadUnliftIO m) => RemoteHostSession -> m () cancelRemoteHostSession = \case RemoteHostSessionStarting {announcer} -> cancel announcer RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient @@ -154,17 +175,31 @@ deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \rh -> do processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse processRemoteCommand RemoteHostSessionStarting {} _ = pure . CRChatError Nothing . ChatError $ CEInternalError "sending remote commands before session started" processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = do - logDebug $ "processRemoteCommand: " <> T.pack (show s) - -- XXX: intercept and filter some commands - -- TODO: store missing files on remote host - relayCommand ctrlClient s + logDebug $ "processRemoteCommand: " <> tshow (s, cmd) + case cmd of + SendFile cn ctrlPath -> do + storeRemoteFile ctrlClient ctrlPath >>= \case + -- TODO: use Left + Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host" + Just hostPath -> relayCommand ctrlClient $ "/file " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath + SendImage cn ctrlPath -> do + storeRemoteFile ctrlClient ctrlPath >>= \case + Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store image on remote host" + Just hostPath -> relayCommand ctrlClient $ "/image " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath + APISendMessage {composedMessage = cm@ComposedMessage {fileSource = Just CryptoFile {filePath = ctrlPath, cryptoArgs}}} -> do + storeRemoteFile ctrlClient ctrlPath >>= \case + Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host" + Just hostPath -> do + let cm' = cm {fileSource = Just CryptoFile {filePath = hostPath, cryptoArgs}} :: ComposedMessage + relayCommand ctrlClient $ B.takeWhile (/= '{') s <> B.toStrict (J.encode cm') + _ -> relayCommand ctrlClient s relayCommand :: (ChatMonad m) => HTTP2Client -> ByteString -> m ChatResponse relayCommand http s = postBytestring Nothing http "/send" mempty s >>= \case Left e -> err $ "relayCommand/post: " <> show e Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - logDebug $ "Got /send response: " <> T.pack (show bodyHead) + logDebug $ "Got /send response: " <> decodeUtf8 bodyHead remoteChatResponse <- case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks Left e -> err $ "relayCommand/decodeValue: " <> show e Right json -> case J.fromJSON $ toTaggedJSON json of @@ -183,61 +218,129 @@ relayCommand http s = where req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) +handleRcvFileComplete :: (ChatMonad m) => HTTP2Client -> FilePath -> User -> CIFile 'MDRcv -> m (Maybe (CIFile 'MDRcv)) +handleRcvFileComplete http storePath remoteUser cif@CIFile {fileId, fileName, fileStatus} = case fileStatus of + CIFSRcvComplete -> + chatReadVar filesFolder >>= \case + Just baseDir -> do + let hostStore = baseDir storePath + createDirectoryIfMissing True hostStore + localPath <- uniqueCombine hostStore fileName + ok <- fetchRemoteFile http remoteUser fileId localPath + pure $ Just (cif {fileName = localPath} :: CIFile 'MDRcv) + Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" + _ -> Nothing <$ logDebug ("Ingoring invalid file notification for file (" <> tshow fileId <> ") " <> tshow fileName) + -- | Convert swift single-field sum encoding into tagged/discriminator-field owsf2tagged :: J.Value -> J.Value owsf2tagged = \case J.Object todo'convert -> J.Object todo'convert skip -> skip -storeRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> m ChatResponse +storeRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> FilePath -> m (Maybe FilePath) storeRemoteFile http localFile = do - postFile Nothing http "/store" mempty localFile >>= \case - Left todo'err -> error "TODO: http2chatError" - Right HTTP2.HTTP2Response {response} -> case HTTP.statusCode <$> HTTP2Client.responseStatus response of - Just 200 -> pure $ CRCmdOk Nothing - todo'notOk -> error "TODO: http2chatError" + putFile Nothing http uri mempty localFile >>= \case + Left h2ce -> Nothing <$ logError (tshow h2ce) + Right HTTP2.HTTP2Response {response, respBody = HTTP2Body {bodyHead}} -> + case HTTP.statusCode <$> HTTP2Client.responseStatus response of + Just 200 -> pure . Just $ B.unpack bodyHead + notOk -> Nothing <$ logError ("Bad response status: " <> tshow notOk) where - postFile timeout c path hs file = liftIO $ do + uri = "/store?" <> HTTP.renderSimpleQuery False [("file_name", utf8String $ takeFileName localFile)] + putFile timeout c path hs file = liftIO $ do fileSize <- fromIntegral <$> getFileSize file HTTP2.sendRequestDirect c (req fileSize) timeout where req size = HTTP2Client.requestFile "PUT" path hs (HTTP2Client.FileSpec file 0 size) -fetchRemoteFile :: (ChatMonad m) => HTTP2Client -> FilePath -> FileTransferId -> m ChatResponse -fetchRemoteFile http storePath remoteFileId = do +fetchRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> User -> Int64 -> FilePath -> m Bool +fetchRemoteFile http User {userId = remoteUserId} remoteFileId localPath = do liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case - Left e -> error "TODO: http2chatError" - Right HTTP2.HTTP2Response {respBody} -> do - error "TODO: stream body into a local file" -- XXX: consult headers for a file name? + Left h2ce -> False <$ logError (tshow h2ce) + Right HTTP2.HTTP2Response {response, respBody} -> + if HTTP2Client.responseStatus response == Just Status.ok200 + then True <$ writeBodyToFile localPath respBody + else False <$ (logError $ "Request failed: " <> maybe "(??)" tshow (HTTP2Client.responseStatus response) <> " " <> decodeUtf8 (bodyHead respBody)) where req = HTTP2Client.requestNoBody "GET" path mempty - path = "/fetch/" <> bshow remoteFileId + path = "/fetch?" <> HTTP.renderSimpleQuery False [("user_id", bshow remoteUserId), ("file_id", bshow remoteFileId)] -processControllerRequest :: forall m . (ChatMonad m) => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () -processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody = HTTP2Body {bodyHead}, sendResponse} = do - logDebug $ "Remote controller request: " <> T.pack (show $ method <> " " <> path) - res <- tryChatError $ case (method, path) of - ("GET", "/") -> getHello - ("POST", "/send") -> sendCommand - ("GET", "/recv") -> recvMessage - ("PUT", "/store") -> storeFile - ("GET", "/fetch") -> fetchFile +-- XXX: extract to Transport.HTTP2 ? +writeBodyToFile :: (MonadUnliftIO m) => FilePath -> HTTP2Body -> m () +writeBodyToFile path HTTP2Body {bodyHead, bodySize, bodyPart} = do + logInfo $ "Receiving " <> tshow bodySize <> " bytes to " <> tshow path + liftIO . withFile path WriteMode $ \h -> do + hPut h bodyHead + mapM_ (hPutBodyChunks h) bodyPart + +hPutBodyChunks :: Handle -> (Int -> IO ByteString) -> IO () +hPutBodyChunks h getChunk = do + chunk <- getChunk defaultHTTP2BufferSize + unless (B.null chunk) $ do + hPut h chunk + hPutBodyChunks h getChunk + +processControllerRequest :: forall m. (ChatMonad m) => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () +processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, sendResponse} = do + logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) + res <- tryChatError $ case (method, ps) of + ("GET", []) -> getHello + ("POST", ["send"]) -> sendCommand + ("GET", ["recv"]) -> recvMessage + ("PUT", ["store"]) -> storeFile + ("GET", ["fetch"]) -> fetchFile unexpected -> respondWith Status.badRequest400 $ "unexpected method/path: " <> Binary.putStringUtf8 (show unexpected) case res of Left e -> logError $ "Error handling remote controller request: (" <> tshow (method <> " " <> path) <> "): " <> tshow e Right () -> logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) <> " OK" where method = fromMaybe "" $ HTTP2Server.requestMethod request - path = fromMaybe "" $ HTTP2Server.requestPath request + path = fromMaybe "/" $ HTTP2Server.requestPath request + (ps, query) = HTTP.decodePath path getHello = respond "OK" - sendCommand = execChatCommand bodyHead >>= respondJSON - recvMessage = chatReadVar remoteCtrlSession >>= \case - Nothing -> respondWith Status.internalServerError500 "session not active" - Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON - storeFile = respondWith Status.notImplemented501 "TODO: storeFile" - fetchFile = respondWith Status.notImplemented501 "TODO: fetchFile" + sendCommand = execChatCommand (bodyHead reqBody) >>= respondJSON + recvMessage = + chatReadVar remoteCtrlSession >>= \case + Nothing -> respondWith Status.internalServerError500 "session not active" + Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON + storeFile = case storeFileQuery of + Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) + Right fileName -> do + baseDir <- fromMaybe "." <$> chatReadVar filesFolder + localPath <- uniqueCombine baseDir fileName + logDebug $ "Storing controller file to " <> tshow (baseDir, localPath) + writeBodyToFile localPath reqBody + let storeRelative = takeFileName localPath + respond $ Binary.putStringUtf8 storeRelative + where + storeFileQuery = parseField "file_name" $ A.many1 (A.satisfy $ not . isPathSeparator) + fetchFile = case fetchFileQuery of + Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) + Right (userId, fileId) -> do + logInfo $ "Fetching file " <> tshow fileId <> " from user " <> tshow userId + x <- withStore' $ \db -> runExceptT $ do + user <- getUser db userId + getRcvFileTransfer db user fileId + case x of + Right RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> do + baseDir <- fromMaybe "." <$> chatReadVar filesFolder + let fullPath = baseDir filePath + size <- fromInteger <$> getFileSize fullPath + liftIO . sendResponse . HTTP2Server.responseFile Status.ok200 mempty $ HTTP2Server.FileSpec fullPath 0 size + Right _ -> respondWith Status.internalServerError500 "The requested file is not complete" + Left SEUserNotFound {} -> respondWith Status.notFound404 "User not found" + Left SERcvFileNotFound {} -> respondWith Status.notFound404 "File not found" + _ -> respondWith Status.internalServerError500 "Store error" + where + fetchFileQuery = + (,) + <$> parseField "user_id" A.decimal + <*> parseField "file_id" A.decimal - respondJSON :: J.ToJSON a => a -> m () + parseField :: ByteString -> A.Parser a -> Either String a + parseField field p = maybe (Left $ "missing " <> B.unpack field) (A.parseOnly $ p <* A.endOfInput) (join $ lookup field query) + + respondJSON :: (J.ToJSON a) => a -> m () respondJSON = respond . Binary.fromLazyByteString . J.encode respond = respondWith Status.ok200 @@ -282,19 +385,20 @@ discoverRemoteCtrls discovered = Discovery.withListener go let addr = THIPv4 (hostAddressToTuple sockAddr) ifM (atomically $ TM.member fingerprint discovered) - (logDebug $ "Fingerprint announce already knwon: " <> T.pack (show (addr, invite))) - (do - logInfo $ "New fingerprint announce: " <> T.pack (show (addr, invite)) - atomically $ TM.insert fingerprint addr discovered + (logDebug $ "Fingerprint announce already knwon: " <> tshow (addr, invite)) + ( do + logInfo $ "New fingerprint announce: " <> tshow (addr, invite) + atomically $ TM.insert fingerprint addr discovered ) withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required - Just found@RemoteCtrl {remoteCtrlId, accepted=storedChoice} -> case storedChoice of + Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui "accept" action required Just False -> pure () -- skipping a rejected item - Just True -> chatReadVar remoteCtrlSession >>= \case - Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" - Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically + Just True -> + chatReadVar remoteCtrlSession >>= \case + Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" + Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically _nonV4 -> go sock registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m ChatResponse @@ -343,10 +447,10 @@ stopRemoteCtrl = toView $ CRRemoteCtrlStopped Nothing pure $ CRCmdOk Nothing -cancelRemoteCtrlSession_ :: MonadUnliftIO m => RemoteCtrlSession -> m () +cancelRemoteCtrlSession_ :: (MonadUnliftIO m) => RemoteCtrlSession -> m () cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () -cancelRemoteCtrlSession :: MonadUnliftIO m => RemoteCtrlSession -> m () -> m () +cancelRemoteCtrlSession :: (MonadUnliftIO m) => RemoteCtrlSession -> m () -> m () cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} cleanup = do cancel discoverer -- may be gone by now case hostServer of @@ -368,3 +472,7 @@ withRemoteCtrl remoteCtrlId action = withStore' (`getRemoteCtrl` remoteCtrlId) >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEMissing {remoteCtrlId} Just rc -> action rc + +utf8String :: [Char] -> ByteString +utf8String = encodeUtf8 . T.pack +{-# INLINE utf8String #-} diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index f9137cdbaf..34e2b04a6f 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -9,13 +10,16 @@ module RemoteTests where import ChatClient import ChatTests.Utils import Control.Monad +import qualified Data.ByteString as B import Data.List.NonEmpty (NonEmpty (..)) +import qualified Data.Map.Strict as M import Debug.Trace import Network.HTTP.Types (ok200) import qualified Network.HTTP2.Client as C import qualified Network.HTTP2.Server as S import qualified Network.Socket as N import qualified Network.TLS as TLS +import qualified Simplex.Chat.Controller as Controller import qualified Simplex.Chat.Remote.Discovery as Discovery import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -24,8 +28,11 @@ import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) +import System.FilePath (makeRelative, ()) import Test.Hspec import UnliftIO +import UnliftIO.Concurrent (threadDelay) +import UnliftIO.Directory remoteTests :: SpecWith FilePath remoteTests = describe "Handshake" $ do @@ -141,6 +148,16 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do remoteCommandTest :: (HasCallStack) => FilePath -> IO () remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do + let mobileFiles = "./tests/tmp/mobile_files" + mobile ##> ("/_files_folder " <> mobileFiles) + mobile <## "ok" + let desktopFiles = "./tests/tmp/desktop_files" + desktop ##> ("/_files_folder " <> desktopFiles) + desktop <## "ok" + let bobFiles = "./tests/tmp/bob_files" + bob ##> ("/_files_folder " <> bobFiles) + bob <## "ok" + desktop ##> "/create remote host" desktop <## "remote host 1 created" desktop <## "connection code:" @@ -178,12 +195,87 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob bob #> "@alice hi" desktop <# "bob> hi" + withXFTPServer $ do + rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) + desktopStore <- case M.lookup 1 rhs of + Just Controller.RemoteHostSessionStarted {storePath} -> pure storePath + _ -> fail "Host session 1 should be started" + + doesFileExist "./tests/tmp/mobile_files/test.pdf" `shouldReturn` False + doesFileExist (desktopFiles desktopStore "test.pdf") `shouldReturn` False + mobileName <- userName mobile + + bobsFile <- makeRelative bobFiles <$> makeAbsolute "tests/fixtures/test.pdf" + bob #> ("/f @" <> mobileName <> " " <> bobsFile) + bob <## "use /fc 1 to cancel sending" + + desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" + desktop <## "use /fr 1 [

b3 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�Ab}c$ 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�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%0AH7zbkH|-g*ZNI-O1<41Z&An;h$a3*! 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�_Y-!p8ZL8S+I{PdRdQzrbi=6PEULdXP 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

/ | ] to receive it" + desktop ##> "/fr 1" + concurrently_ + do + bob <## "started sending file 1 (test.pdf) to alice" + bob <## "completed sending file 1 (test.pdf) to alice" + + do + desktop <## "saving file 1 from bob to test.pdf" + desktop <## "started receiving file 1 (test.pdf) from bob" + + let desktopReceived = desktopFiles desktopStore "test.pdf" + desktop <## ("completed receiving file 1 (" <> desktopReceived <> ") from bob") + bobsFileSize <- getFileSize bobsFile + getFileSize desktopReceived `shouldReturn` bobsFileSize + bobsFileBytes <- B.readFile bobsFile + B.readFile desktopReceived `shouldReturn` bobsFileBytes + + -- test file transit on mobile + mobile ##> "/fs 1" + mobile <## "receiving file 1 (test.pdf) complete, path: test.pdf" + getFileSize (mobileFiles "test.pdf") `shouldReturn` bobsFileSize + B.readFile (mobileFiles "test.pdf") `shouldReturn` bobsFileBytes + + traceM " - file received" + + desktopFile <- makeRelative desktopFiles <$> makeAbsolute "tests/fixtures/logo.jpg" -- XXX: not necessary for _send, but required for /f + traceM $ " - sending " <> show desktopFile + doesFileExist (bobFiles "logo.jpg") `shouldReturn` False + doesFileExist (mobileFiles "logo.jpg") `shouldReturn` False + desktop ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/logo.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" + desktop <# "@bob hi, sending a file" + desktop <# "/f @bob logo.jpg" + desktop <## "use /fc 2 to cancel sending" + + bob <# "alice> hi, sending a file" + bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" + bob <## "use /fr 2 [/ | ] to receive it" + bob ##> "/fr 2" + concurrently_ + do + bob <## "saving file 2 from alice to logo.jpg" + bob <## "started receiving file 2 (logo.jpg) from alice" + bob <## "completed receiving file 2 (logo.jpg) from alice" + bob ##> "/fs 2" + bob <## "receiving file 2 (logo.jpg) complete, path: logo.jpg" + do + desktop <## "started sending file 2 (logo.jpg) to bob" + desktop <## "completed sending file 2 (logo.jpg) to bob" + desktopFileSize <- getFileSize desktopFile + getFileSize (bobFiles "logo.jpg") `shouldReturn` desktopFileSize + getFileSize (mobileFiles "logo.jpg") `shouldReturn` desktopFileSize + + desktopFileBytes <- B.readFile desktopFile + B.readFile (bobFiles "logo.jpg") `shouldReturn` desktopFileBytes + B.readFile (mobileFiles "logo.jpg") `shouldReturn` desktopFileBytes + + traceM " - file sent" + traceM " - post-remote checks" mobile ##> "/stop remote ctrl" mobile <## "ok" concurrently_ (mobile <## "remote controller stopped") (desktop <## "remote host 1 stopped") + mobile ##> "/contacts" mobile <## "bob (Bob)" From 4ecf94dfad04d93a4515a6e0b4c7338a0b8487fc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 09:50:11 +0100 Subject: [PATCH 055/294] core: move CLI notifications and active chat to view layer (for remote CLI) (#3196) * core: move CLI notifications to view layer (to allow notifications in remote CLI) * remove unused * refactor activeTo * move activeTo to ChatTerminal * refactor * move back * remove extension --- apps/simplex-bot-advanced/Main.hs | 2 +- apps/simplex-bot/Main.hs | 2 +- apps/simplex-broadcast-bot/Main.hs | 2 +- apps/simplex-chat/Main.hs | 2 +- apps/simplex-chat/Server.hs | 2 +- apps/simplex-directory-service/Main.hs | 2 +- src/Simplex/Chat.hs | 183 +++++++++------------- src/Simplex/Chat/Controller.hs | 25 +-- src/Simplex/Chat/Core.hs | 6 +- src/Simplex/Chat/Messages.hs | 6 +- src/Simplex/Chat/Mobile.hs | 2 +- src/Simplex/Chat/Store/Profiles.hs | 2 +- src/Simplex/Chat/Terminal.hs | 8 +- src/Simplex/Chat/Terminal/Input.hs | 38 +++-- src/Simplex/Chat/Terminal/Notification.hs | 3 +- src/Simplex/Chat/Terminal/Output.hs | 133 +++++++++++++++- src/Simplex/Chat/Types.hs | 2 - src/Simplex/Chat/View.hs | 2 +- tests/Bots/BroadcastTests.hs | 2 +- tests/Bots/DirectoryTests.hs | 2 +- tests/ChatClient.hs | 4 +- 21 files changed, 249 insertions(+), 181 deletions(-) diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 04d8e4ffa1..2af76d9961 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -24,7 +24,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore terminalChatConfig opts Nothing mySquaringBot + simplexChatCore terminalChatConfig opts mySquaringBot welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do diff --git a/apps/simplex-bot/Main.hs b/apps/simplex-bot/Main.hs index d4ad9f9079..c24f9c251f 100644 --- a/apps/simplex-bot/Main.hs +++ b/apps/simplex-bot/Main.hs @@ -13,7 +13,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore terminalChatConfig opts Nothing $ + simplexChatCore terminalChatConfig opts $ chatBotRepl welcomeMessage $ \_contact msg -> pure $ case readMaybe msg :: Maybe Integer of Just n -> msg <> " * " <> msg <> " = " <> show (n * n) diff --git a/apps/simplex-broadcast-bot/Main.hs b/apps/simplex-broadcast-bot/Main.hs index 15bb743b56..3130437e0f 100644 --- a/apps/simplex-broadcast-bot/Main.hs +++ b/apps/simplex-broadcast-bot/Main.hs @@ -8,4 +8,4 @@ import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ broadcastBot opts + simplexChatCore terminalChatConfig (mkChatOpts opts) $ broadcastBot opts diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 8dd02623e2..f5d95e57f0 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -27,7 +27,7 @@ main = do welcome opts t <- withTerminal pure simplexChatTerminal terminalChatConfig opts t - else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do + else simplexChatCore terminalChatConfig opts $ \user cc -> do r <- sendChatCmdStr cc chatCmd ts <- getCurrentTime tz <- getCurrentTimeZone diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 6f198340f8..d96350cd29 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -30,7 +30,7 @@ import UnliftIO.STM simplexChatServer :: ChatServerConfig -> ChatConfig -> ChatOpts -> IO () simplexChatServer srvCfg cfg opts = - simplexChatCore cfg opts Nothing . const $ runChatServer srvCfg + simplexChatCore cfg opts . const $ runChatServer srvCfg data ChatServerConfig = ChatServerConfig { chatPort :: ServiceName, diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index 434e42d851..af9c9dd252 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -12,4 +12,4 @@ main :: IO () main = do opts@DirectoryOpts {directoryLog} <- welcomeGetOpts st <- restoreDirectoryStore directoryLog - simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ directoryService st opts + simplexChatCore terminalChatConfig (mkChatOpts opts) $ directoryService st opts diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 6f43f5c0f8..5e2a231c97 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -183,13 +183,11 @@ createChatDatabase filePrefix key confirmMigrations = runExceptT $ do agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key confirmMigrations pure ChatDatabase {chatStore, agentStore} -newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} sendToast = do +newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController +newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize} - sendNotification = fromMaybe (const $ pure ()) sendToast firstTime = dbNew chatStore - activeTo <- newTVarIO ActiveNone currentUser <- newTVarIO user servers <- agentServers config smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore @@ -197,7 +195,6 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen idsDrg <- newTVarIO =<< liftIO drgNew inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize - notifyQ <- newTBQueueIO tbqSize subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO sndFiles <- newTVarIO M.empty @@ -213,7 +210,34 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir contactMergeEnabled <- newTVarIO True - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile, contactMergeEnabled} + pure + ChatController + { firstTime, + currentUser, + smpAgent, + agentAsync, + chatStore, + chatStoreChanged, + idsDrg, + inputQ, + outputQ, + subscriptionMode, + chatLock, + sndFiles, + rcvFiles, + currentCalls, + config, + filesFolder, + expireCIThreads, + expireCIFlags, + cleanupManagerAsync, + timedItemThreads, + showLiveItems, + userXFTPFileConfig, + tempDirectory, + logFilePath = logFile, + contactMergeEnabled + } where configServers :: DefaultAgentServers configServers = @@ -260,7 +284,7 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do readTVarIO s >>= maybe (start s users) (pure . fst) where start s users = do - a1 <- async $ race_ notificationSubscriber agentSubscriber + a1 <- async agentSubscriber a2 <- if subConns then Just <$> async (subscribeUsers False users) @@ -376,7 +400,6 @@ processChatCommand = \case user <- withStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts storeServers user smpServers storeServers user xftpServers - setActive ActiveNone atomically . writeTVar u $ Just user pure $ CRActiveUser user where @@ -402,7 +425,6 @@ processChatCommand = \case user' <- privateGetUser userId' validateUserPassword user user' viewPwd_ withStoreCtx' (Just "APISetActiveUser, setActiveUser") $ \db -> setActiveUser db userId' - setActive ActiveNone let user'' = user' {activeUser = True} asks currentUser >>= atomically . (`writeTVar` Just user'') pure $ CRActiveUser user'' @@ -532,7 +554,7 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIGetChatItems pagination search -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user pagination search - pure $ CRChatItems user chatItems + pure $ CRChatItems user Nothing chatItems APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do (aci@(AChatItem cType dir _ ci), versions) <- withStore $ \db -> (,) <$> getAChatItem db user chatRef itemId <*> liftIO (getChatItemVersions db itemId) @@ -546,7 +568,7 @@ processChatCommand = \case pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses} APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of CTDirect -> do - ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId + ct@Contact {contactId, contactUsed} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgNew_ unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct if isVoice mc && not (featureAllowed SCFVoice forUser ct) @@ -563,7 +585,6 @@ processChatCommand = \case ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) - setActive $ ActiveC c pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) where setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) @@ -614,7 +635,7 @@ processChatCommand = \case assertUserGroupRole gInfo GRAuthor send g where - send g@(Group gInfo@GroupInfo {groupId, membership, localDisplayName = gName} ms) + send g@(Group gInfo@GroupInfo {groupId, membership} ms) | isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) = notAllowedError GFVoice | not (isVoice mc) && isJust file_ && not (groupFeatureAllowed SGFFiles gInfo) = notAllowedError GFFiles | otherwise = do @@ -629,7 +650,6 @@ processChatCommand = \case createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) - setActive $ ActiveG gName pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f)) setupSndFileTransfer :: Group -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) @@ -734,7 +754,7 @@ processChatCommand = \case unzipMaybe3 _ = (Nothing, Nothing, Nothing) APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> withChatLock "updateChatItem" $ case cType of CTDirect -> do - (ct@Contact {contactId, localDisplayName = c}, cci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct@Contact {contactId}, cci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId assertDirectAllowed user MDSnd ct XMsgUpdate_ case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do @@ -750,13 +770,12 @@ processChatCommand = \case addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' - setActive $ ActiveC c pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> do - Group gInfo@GroupInfo {groupId, localDisplayName = gName} ms <- withStore $ \db -> getGroup db user chatId + Group gInfo@GroupInfo {groupId} ms <- withStore $ \db -> getGroup db user chatId assertUserGroupRole gInfo GRAuthor cci <- withStore $ \db -> getGroupChatItem db user chatId itemId case cci of @@ -773,7 +792,6 @@ processChatCommand = \case addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - setActive $ ActiveG gName pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) _ -> throwChatError CEInvalidChatItemUpdate @@ -782,13 +800,12 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of CTDirect -> do - (ct@Contact {localDisplayName = c}, ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}})) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}})) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteDirectCI user ct ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do assertDirectAllowed user MDSnd ct XMsgDel_ (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgDel itemSharedMId Nothing) - setActive $ ActiveC c if featureAllowed SCFFullDelete forUser ct then deleteDirectCI user ct ci True False else markDirectCIDeleted user ct ci msgId True =<< liftIO getCurrentTime @@ -898,7 +915,7 @@ processChatCommand = \case _ -> pure $ chatCmdError (Just user) "not supported" APIDeleteChat (ChatRef cType chatId) notify -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId + ct <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withChatLock "deleteChat direct" . procCmd $ do deleteFilesAndConns user filesInfo @@ -910,7 +927,6 @@ processChatCommand = \case -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct withStore' $ \db -> deleteContact db user ct - unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted user ct CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withStore $ \db -> getPendingContactConnection db userId chatId @@ -1698,11 +1714,10 @@ processChatCommand = \case LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search - setActive $ chatActiveTo chatName - pure $ CRChatItems user (aChatItems . chat $ chatResp) + pure $ CRChatItems user (Just chatName) (aChatItems . chat $ chatResp) LastMessages Nothing count search -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user (CPLast count) search - pure $ CRChatItems user chatItems + pure $ CRChatItems user Nothing chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) @@ -1714,10 +1729,10 @@ processChatCommand = \case chatItem <- withStore $ \db -> do chatRef <- getChatRefViaItemId db user itemId getAChatItem db user chatRef itemId - pure $ CRChatItems user ((: []) chatItem) + pure $ CRChatItems user Nothing ((: []) chatItem) ShowChatItem Nothing -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user (CPLast 1) Nothing - pure $ CRChatItems user chatItems + pure $ CRChatItems user Nothing chatItems ShowChatItemInfo chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName itemId <- getChatItemIdByText user chatRef msg @@ -2059,8 +2074,7 @@ processChatCommand = \case when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive delGroupChatItem :: User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> m ChatResponse - delGroupChatItem user gInfo@GroupInfo {localDisplayName = gName} ci msgId byGroupMember = do - setActive $ ActiveG gName + delGroupChatItem user gInfo ci msgId byGroupMember = do deletedTs <- liftIO getCurrentTime if groupFeatureAllowed SGFFullDelete gInfo then deleteGroupCI user gInfo ci True False byGroupMember deletedTs @@ -2117,7 +2131,6 @@ processChatCommand = \case let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd ct) msg content toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) - setActive $ ActiveG localDisplayName sndContactCITimed :: Bool -> Contact -> Maybe Int -> m (Maybe CITimed) sndContactCITimed live = sndCITimed_ live . contactTimedTTL sndGroupCITimed :: Bool -> GroupInfo -> Maybe Int -> m (Maybe CITimed) @@ -2167,7 +2180,6 @@ processChatCommand = \case users <- withStore' getUsers unless (length users > 1 && (isJust (viewPwdHash user) || length (filter (isNothing . viewPwdHash) users) > 1)) $ throwChatError (CECantDeleteLastUser userId) - setActive ActiveNone deleteChatUser :: User -> Bool -> m ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withStore' (`getUserFileInfo` user) @@ -2867,17 +2879,16 @@ processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h DISCONNECT p h -> hostEvent $ CRHostDisconnected p h - DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" - UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" + DOWN srv conns -> serverEvent srv conns CRContactsDisconnected + UP srv conns -> serverEvent srv conns CRContactsSubscribed SUSPENDED -> toView CRChatSuspended DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId where hostEvent :: ChatResponse -> m () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv@(SMPServer host _ _) conns event str = do - cs <- withStore' $ \db -> getConnectionsContacts db conns + serverEvent srv conns event = do + cs <- withStore' (`getConnectionsContacts` conns) toView $ event srv cs - showToast ("server " <> str) (safeDecodeUtf8 $ strEncode host) processAgentMsgSndFile :: forall m. ChatMonad m => ACorrId -> SndFileId -> ACommand 'Agent 'AESndFile -> m () processAgentMsgSndFile _corrId aFileId msg = @@ -3014,10 +3025,7 @@ processAgentMsgRcvFile _corrId aFileId msg = processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessageConn user _ agentConnId END = withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case - RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do - toView $ CRContactAnotherClient user ct - whenUserNtfs user $ showToast (c <> "> ") "connected to another client" - unsetActive $ ActiveC c + RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct entity -> toView $ CRSubscriptionEnd user entity processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do entity <- withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus @@ -3084,7 +3092,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () - Just ct@Contact {localDisplayName = c, contactId} -> case agentMsg of + Just ct@Contact {contactId} -> case agentMsg of INV (ACR _ cReq) -> -- [async agent commands] XGrpMemIntro continuation on receiving INV withCompletedCommand conn agentMsg $ \_ -> @@ -3169,9 +3177,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct) $ createFeatureEnabledItems ct - whenUserNtfs user $ do - setActive $ ActiveC c - showToast (c <> "> ") "connected" when (contactConnInitiated conn) $ do let Connection {groupLinkId} = conn doProbeContacts = isJust groupLinkId @@ -3248,7 +3253,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> pure () processGroupMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> m () - processGroupMessage agentMsg connEntity conn@Connection {connId, connectionCode} gInfo@GroupInfo {groupId, localDisplayName = gName, groupProfile, membership, chatSettings} m = case agentMsg of + processGroupMessage agentMsg connEntity conn@Connection {connId, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of INV (ACR _ cReq) -> withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> case cReq of @@ -3338,15 +3343,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m forM_ description $ groupDescriptionChatItem gInfo m - whenUserNtfs user $ do - setActive $ ActiveG gName - showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do memberConnectedChatItem gInfo m toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} - whenGroupNtfs user gInfo $ do - setActive $ ActiveG gName - showToast ("#" <> gName) $ "member " <> m.localDisplayName <> " is connected" intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> @@ -3642,7 +3641,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do profileContactRequest invId chatVRange p xContactId_ = do withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact - CORRequest cReq@UserContactRequest {localDisplayName} -> do + CORRequest cReq -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case Just (UserContactLink {autoAccept}, groupId_, _) -> case autoAccept of @@ -3657,10 +3656,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo ct <- acceptContactRequestAsync user cReq profileMode toView $ CRAcceptingGroupJoinRequest user gInfo ct - _ -> do - toView $ CRReceivedContactRequest user cReq - whenUserNtfs user $ - showToast (localDisplayName <> "> ") "wants to connect to you" + _ -> toView $ CRReceivedContactRequest user cReq _ -> pure () incAuthErrCounter :: ConnectionEntity -> Connection -> AgentErrorType -> m () @@ -3751,13 +3747,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvMsgContent $ MCText descr) Nothing notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> m () - notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} ct_ = do + notifyMemberConnected gInfo m ct_ = do memberConnectedChatItem gInfo m toView $ CRConnectedToGroupMember user gInfo m ct_ - let g = groupName' gInfo - whenGroupNtfs user gInfo $ do - setActive $ ActiveG g - showToast ("#" <> g) $ "member " <> c <> " is connected" probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do @@ -3819,7 +3811,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageError = toView . CRMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newContentMessage ct@Contact {localDisplayName = c, contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do + newContentMessage ct@Contact {contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct checkIntegrityCreateItem (CDDirectRcv ct) msgMeta let ExtMsgContent content fInv_ _ _ = mcExtMsgContent mc @@ -3832,23 +3824,18 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do if isVoice content && not (featureAllowed SCFVoice forContact ct) then do void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing Nothing False - setActive $ ActiveC c else do let ExtMsgContent _ _ itemTTL live_ = mcExtMsgContent mc timed_ = rcvContactCITimed ct itemTTL live = fromMaybe False live_ file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct - ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live + newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live autoAcceptFile file_ - whenContactNtfs user ct $ do - showMsgToast (c <> "> ") content formattedText - setActive $ ActiveC c where newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}) - pure ci autoAcceptFile :: Maybe (RcvFileTransfer, CIFile 'MDRcv) -> m () autoAcceptFile = mapM_ $ \(ft, CIFile {fileSize}) -> do @@ -3907,7 +3894,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do pure (ft, CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () - messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do + messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta updateRcvChatItem `catchCINotFound` \_ -> do -- This patches initial sharedMsgId into chat item when locally deleted chat item @@ -3919,7 +3906,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content live Nothing toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') - setActive $ ActiveC c where MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc @@ -4006,7 +3992,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do e -> throwError e newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newGroupContentMessage gInfo m@GroupMember {localDisplayName = c, memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta + newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles | otherwise = do @@ -4036,20 +4022,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView cr createItem timed_ live = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m - ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live + newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live autoAcceptFile file_ - let g = groupName' gInfo - whenGroupNtfs user gInfo $ do - showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText - setActive $ ActiveG g newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ groupMsgToView gInfo m ci {reactions} msgMeta - pure ci groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () - groupMessageUpdate gInfo@GroupInfo {groupId, localDisplayName = g} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = + groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = updateRcvChatItem `catchCINotFound` \_ -> do -- This patches initial sharedMsgId into chat item when locally deleted chat item -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). @@ -4060,7 +4041,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createChatItemVersion db (chatItemId' ci) brokerTs mc updateGroupChatItem db user groupId ci content live Nothing toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - setActive $ ActiveG g where MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc @@ -4079,7 +4059,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) updateGroupChatItem db user groupId ci content live $ Just msgId toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - setActive $ ActiveG g startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) else messageError "x.msg.update: group member attempted to update a message of another member" @@ -4115,7 +4094,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processFileInvitation' ct@Contact {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do + processFileInvitation' ct fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize @@ -4124,13 +4103,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) - whenContactNtfs user ct $ do - showToast (c <> "> ") "wants to send a file" - setActive $ ActiveC c -- TODO remove once XFile is discontinued processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processGroupFileInvitation' gInfo m@GroupMember {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do + processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize @@ -4138,10 +4114,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False groupMsgToView gInfo m ci msgMeta - let g = groupName' gInfo - whenGroupNtfs user gInfo $ do - showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" - setActive $ ActiveG g receiveInlineMode :: FileInvitation -> Maybe MsgContent -> Integer -> m (Maybe InlineFileMode) receiveInlineMode FileInvitation {fileSize, fileInline, fileDescr} mc_ chSize = case (fileInline, fileDescr) of @@ -4322,8 +4294,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} - whenContactNtfs user ct $ - showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" where sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool sameGroupLinkId (Just gli) (Just gli') = gli == gli' @@ -5470,29 +5440,20 @@ getCreateActiveUser st testView = do getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine -whenUserNtfs :: ChatMonad' m => User -> m () -> m () -whenUserNtfs User {showNtfs, activeUser} = when $ showNtfs || activeUser +userNtf :: User -> Bool +userNtf User {showNtfs, activeUser} = showNtfs || activeUser -whenContactNtfs :: ChatMonad' m => User -> Contact -> m () -> m () -whenContactNtfs user Contact {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings) +chatNtf :: User -> ChatInfo c -> Bool +chatNtf user = \case + DirectChat ct -> contactNtf user ct + GroupChat g -> groupNtf user g + _ -> False -whenGroupNtfs :: ChatMonad' m => User -> GroupInfo -> m () -> m () -whenGroupNtfs user GroupInfo {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings) +contactNtf :: User -> Contact -> Bool +contactNtf user Contact {chatSettings} = userNtf user && enableNtfs chatSettings -showMsgToast :: ChatMonad' m => Text -> MsgContent -> Maybe MarkdownList -> m () -showMsgToast from mc md_ = showToast from $ maybe (msgContentText mc) (mconcat . map hideSecret) md_ - where - hideSecret :: FormattedText -> Text - hideSecret FormattedText {format = Just Secret} = "..." - hideSecret FormattedText {text} = text - -showToast :: ChatMonad' m => Text -> Text -> m () -showToast title text = atomically . (`writeTBQueue` Notification {title, text}) =<< asks notifyQ - -notificationSubscriber :: ChatMonad' m => m () -notificationSubscriber = do - ChatController {notifyQ, sendNotification} <- ask - forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification +groupNtf :: User -> GroupInfo -> Bool +groupNtf user GroupInfo {chatSettings} = userNtf user && enableNtfs chatSettings withUser' :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser' action = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3466371da8..d97d59c5c5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -34,8 +34,7 @@ import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import Data.String import Data.Text (Text) -import Data.Time (NominalDiffTime) -import Data.Time.Clock (UTCTime) +import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) import GHC.Generics (Generic) import Language.Haskell.TH (Exp, Q, runIO) @@ -153,20 +152,10 @@ defaultInlineFilesConfig = receiveInstant = True -- allow receiving instant files, within receiveChunks limit } -data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName - deriving (Eq) - -chatActiveTo :: ChatName -> ActiveTo -chatActiveTo (ChatName cType name) = case cType of - CTDirect -> ActiveC name - CTGroup -> ActiveG name - _ -> ActiveNone - data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLiteStore} data ChatController = ChatController { currentUser :: TVar (Maybe User), - activeTo :: TVar ActiveTo, firstTime :: Bool, smpAgent :: AgentClient, agentAsync :: TVar (Maybe (Async (), Maybe (Async ()))), @@ -175,8 +164,6 @@ data ChatController = ChatController idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe CorrId, ChatResponse), - notifyQ :: TBQueue Notification, - sendNotification :: Notification -> IO (), subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, sndFiles :: TVar (Map Int64 Handle), @@ -433,7 +420,7 @@ data ChatResponse | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat} - | CRChatItems {user :: User, chatItems :: [AChatItem]} + | CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} @@ -1074,14 +1061,6 @@ mkChatError = ChatError . CEException . show chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError -setActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () -setActive to = asks activeTo >>= atomically . (`writeTVar` to) - -unsetActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () -unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset) - where - unset a' = if a == a' then ActiveNone else a' - toView :: ChatMonad' m => ChatResponse -> m () toView event = do q <- asks outputQ diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 4af161ab41..870779cfda 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -14,8 +14,8 @@ import Simplex.Chat.Types import System.Exit (exitFailure) import UnliftIO.Async -simplexChatCore :: ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} sendToast chat = +simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () +simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} chat = case logAgent of Just level -> do setLogLevel level @@ -28,7 +28,7 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core exitFailure run db@ChatDatabase {chatStore} = do u <- getCreateActiveUser chatStore testView - cc <- newChatController db (Just u) cfg opts sendToast + cc <- newChatController db (Just u) cfg opts runSimplexChat opts u cc chat runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 79463d2107..3831fad03e 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -50,8 +50,10 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord, Generic) -data ChatName = ChatName ChatType Text - deriving (Show) +data ChatName = ChatName {chatType :: ChatType, chatName :: Text} + deriving (Show, Generic) + +instance ToJSON ChatName where toEncoding = J.genericToEncoding J.defaultOptions chatTypeStr :: ChatType -> String chatTypeStr = \case diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 3d841f18c4..0a970d2c8e 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -196,7 +196,7 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do where initialize st db = do user_ <- getActiveUser_ st - newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix dbKey) Nothing + newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix dbKey) migrate createStore dbFile confirmMigrations = ExceptT $ (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey confirmMigrations) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 5b5a6eb671..fa573e4e60 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -87,7 +87,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) +import Simplex.Messaging.Util (safeDecodeUtf8) createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 0ef3d3bace..68aaa51318 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -15,7 +15,6 @@ import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Options import Simplex.Chat.Terminal.Input -import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Client (defaultNetworkConfig) @@ -40,10 +39,9 @@ terminalChatConfig = } simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () -simplexChatTerminal cfg opts t = do - sendToast <- if muteNotifications opts then pure Nothing else Just <$> initializeNotifications - handle checkDBKeyError . simplexChatCore cfg opts sendToast $ \u cc -> do - ct <- newChatTerminal t +simplexChatTerminal cfg opts t = + handle checkDBKeyError . simplexChatCore cfg opts $ \u cc -> do + ct <- newChatTerminal t opts when (firstTime cc) . printToTerminal ct $ chatWelcome u runChatTerminal ct cc diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 8841f15ffd..0fd95cf680 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -57,14 +57,26 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do cmd = parseChatCommand bs unless (isMessage cmd) $ echo s r <- runReaderT (execChatCommand bs) cc - case r of - CRChatCmdError _ _ -> when (isMessage cmd) $ echo s - CRChatError _ _ -> when (isMessage cmd) $ echo s - _ -> pure () + processResp s cmd r printRespToTerminal ct cc False r startLiveMessage cmd r where echo s = printToTerminal ct [plain s] + processResp s cmd = \case + CRActiveUser _ -> setActive ct "" + CRChatItems u chatName_ _ -> whenCurrUser cc u $ mapM_ (setActive ct . chatActiveTo) chatName_ + CRNewChatItem u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo + CRChatItemUpdated u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo + CRChatItemDeleted u (AChatItem _ _ cInfo _) _ _ _ -> whenCurrUser cc u $ setActiveChat ct cInfo + CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c + CRGroupDeletedUser u g -> whenCurrUser cc u $ unsetActiveGroup ct g + CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g + CRChatCmdError _ _ -> when (isMessage cmd) $ echo s + CRChatError _ _ -> when (isMessage cmd) $ echo s + CRCmdOk _ -> case cmd of + Right APIDeleteUser {} -> setActive ct "" + _ -> pure () + _ -> pure () isMessage = \case Right SendMessage {} -> True Right SendLiveMessage {} -> True @@ -134,7 +146,7 @@ runTerminalInput ct cc = withChatTerm ct $ do receiveFromTTY cc ct receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m () -receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState} = +receiveFromTTY cc@ChatController {inputQ, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} = forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct) where processKey :: (Key, Modifiers) -> IO () @@ -153,11 +165,11 @@ receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@C when (inputString ts /= "" || isLive) $ atomically (submitInput live ts) >>= mapM_ (uncurry endLiveMessage) update key = do - ac <- readTVarIO activeTo + chatPrefix <- readTVarIO activeTo live <- isJust <$> readTVarIO liveMessageState ts <- readTVarIO termState user_ <- readTVarIO currentUser - ts' <- updateTermState user_ chatStore ac live (width termSize) key ts + ts' <- updateTermState user_ chatStore chatPrefix live (width termSize) key ts atomically $ writeTVar termState $! ts' endLiveMessage :: String -> LiveMessage -> IO () @@ -203,8 +215,8 @@ data AutoComplete | ACCommand Text | ACNone -updateTermState :: Maybe User -> SQLiteStore -> ActiveTo -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState -updateTermState user_ st ac live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p, autoComplete = acp} = case key of +updateTermState :: Maybe User -> SQLiteStore -> String -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState +updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p, autoComplete = acp} = case key of CharKey c | ms == mempty || ms == shiftKey -> pure $ insertChars $ charsWithContact [c] | ms == altKey && c == 'b' -> pure $ setPosition prevWordPos @@ -326,17 +338,13 @@ updateTermState user_ st ac live tw (key, ms) ts@TerminalState {inputString = s, charsWithContact cs | live = cs | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" = - contactPrefix <> cs + chatPrefix <> cs | (s == ">" || s == "\\" || s == "!") && cs == " " = - cs <> contactPrefix + cs <> chatPrefix | otherwise = cs insertChars = ts' . if p >= length s then append else insert append cs = let s' = s <> cs in (s', length s') insert cs = let (b, a) = splitAt p s in (b <> cs <> a, p + length cs) - contactPrefix = case ac of - ActiveNone -> "" - ActiveC c -> "@" <> T.unpack c <> " " - ActiveG g -> "#" <> T.unpack g <> " " backDeleteChar | p == 0 || null s = ts | p >= length s = ts' (init s, length s - 1) diff --git a/src/Simplex/Chat/Terminal/Notification.hs b/src/Simplex/Chat/Terminal/Notification.hs index 98031fe525..87bed5be1a 100644 --- a/src/Simplex/Chat/Terminal/Notification.hs +++ b/src/Simplex/Chat/Terminal/Notification.hs @@ -13,13 +13,14 @@ import qualified Data.Map as M import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T -import Simplex.Chat.Types import Simplex.Messaging.Util (catchAll_) import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getAppUserDataDirectory) import System.FilePath (combine) import System.Info (os) import System.Process (readCreateProcess, shell) +data Notification = Notification {title :: Text, text :: Text} + initializeNotifications :: IO (Notification -> IO ()) initializeNotifications = hideException <$> case os of diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index db6f16f3ca..556e4f792e 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -3,6 +3,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -14,13 +15,24 @@ import Control.Monad.Catch (MonadMask) import Control.Monad.Except import Control.Monad.Reader import Data.List (intercalate) +import Data.Text (Text) +import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) -import Simplex.Chat (processChatCommand) +import Simplex.Chat (processChatCommand, chatNtf, contactNtf, groupNtf, userNtf) import Simplex.Chat.Controller -import Simplex.Chat.Messages hiding (NewChatItem (..)) +import Simplex.Chat.Markdown +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent (CIContent(..), SMsgDirection (..)) +import Simplex.Chat.Options +import Simplex.Chat.Protocol (MsgContent (..), msgContentText) import Simplex.Chat.Styled +import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) +import Simplex.Chat.Types (Contact, GroupInfo (..), User (..), UserContactRequest (..)) import Simplex.Chat.View +import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types import System.IO (IOMode (..), hPutStrLn, withFile) import System.Mem.Weak (Weak) @@ -34,7 +46,9 @@ data ChatTerminal = ChatTerminal termSize :: Size, liveMessageState :: TVar (Maybe LiveMessage), nextMessageRow :: TVar Int, - termLock :: TMVar () + termLock :: TMVar (), + sendNotification :: Maybe (Notification -> IO ()), + activeTo :: TVar String } data TerminalState = TerminalState @@ -79,16 +93,28 @@ instance WithTerminal VirtualTerminal where withChatTerm :: (MonadIO m, MonadMask m) => ChatTerminal -> (forall t. WithTerminal t => TerminalT t m a) -> m a withChatTerm ChatTerminal {termDevice = TerminalDevice t} action = withTerm t $ runTerminalT action -newChatTerminal :: WithTerminal t => t -> IO ChatTerminal -newChatTerminal t = do +newChatTerminal :: WithTerminal t => t -> ChatOpts -> IO ChatTerminal +newChatTerminal t opts = do termSize <- withTerm t . runTerminalT $ getWindowSize let lastRow = height termSize - 1 termState <- newTVarIO mkTermState liveMessageState <- newTVarIO Nothing termLock <- newTMVarIO () nextMessageRow <- newTVarIO lastRow + sendNotification <- if muteNotifications opts then pure Nothing else Just <$> initializeNotifications + activeTo <- newTVarIO "" -- threadDelay 500000 -- this delay is the same as timeout in getTerminalSize - return ChatTerminal {termDevice = TerminalDevice t, termState, termSize, liveMessageState, nextMessageRow, termLock} + pure + ChatTerminal + { termDevice = TerminalDevice t, + termState, + termSize, + liveMessageState, + nextMessageRow, + termLock, + sendNotification, + activeTo + } mkTermState :: TerminalState mkTermState = @@ -122,6 +148,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d _ -> printToTerminal ct liveItems <- readTVarIO showLiveItems responseString cc liveItems r >>= printResp + responseNotification ct cc r where markChatItemRead (AChatItem _ _ chat item@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = case (muted chat chatDir, itemStatus) of @@ -132,6 +159,100 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s +responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () +responseNotification t@ChatTerminal {sendNotification} cc = \case + CRNewChatItem u (AChatItem _ SMDRcv cInfo ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) -> + when (chatNtf u cInfo) $ do + whenCurrUser cc u $ setActiveChat t cInfo + case (cInfo, chatDir) of + (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) + (GroupChat g, CIGroupRcv m) -> sendNtf (fromGroup_ g m, text) + _ -> pure () + where + text = msgText mc formattedText + CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ChatItem {content = CIRcvMsgContent _}) -> + whenCurrUser cc u $ when (chatNtf u cInfo) $ setActiveChat t cInfo + CRContactConnected u ct _ -> when (contactNtf u ct) $ do + whenCurrUser cc u $ setActiveContact t ct + sendNtf (viewContactName ct <> "> ", "connected") + CRContactAnotherClient u ct -> do + whenCurrUser cc u $ unsetActiveContact t ct + when (contactNtf u ct) $ sendNtf (viewContactName ct <> "> ", "connected to another client") + CRContactsDisconnected srv _ -> serverNtf srv "disconnected" + CRContactsSubscribed srv _ -> serverNtf srv "connected" + CRReceivedGroupInvitation u g ct _ _ -> + when (contactNtf u ct) $ + sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") + CRUserJoinedGroup u g _ -> when (groupNtf u g) $ do + whenCurrUser cc u $ setActiveGroup t g + sendNtf ("#" <> viewGroupName g, "you are connected to group") + CRJoinedGroupMember u g m -> + when (groupNtf u g) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + CRConnectedToGroupMember u g m _ -> + when (groupNtf u g) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + CRReceivedContactRequest u UserContactRequest {localDisplayName = n} -> + when (userNtf u) $ sendNtf (viewName n <> ">", "wants to connect to you") + _ -> pure () + where + sendNtf = maybe (\_ -> pure ()) (. uncurry Notification) sendNotification + serverNtf (SMPServer host _ _) str = sendNtf ("server " <> str, safeDecodeUtf8 $ strEncode host) + +msgText :: MsgContent -> Maybe MarkdownList -> Text +msgText (MCFile _) _ = "wants to send a file" +msgText mc md_ = maybe (msgContentText mc) (mconcat . map hideSecret) md_ + where + hideSecret :: FormattedText -> Text + hideSecret FormattedText {format = Just Secret} = "..." + hideSecret FormattedText {text} = text + +chatActiveTo :: ChatName -> String +chatActiveTo (ChatName cType name) = case cType of + CTDirect -> T.unpack $ "@" <> viewName name <> " " + CTGroup -> T.unpack $ "#" <> viewName name <> " " + _ -> "" + +chatInfoActiveTo :: ChatInfo c -> String +chatInfoActiveTo = \case + DirectChat c -> contactActiveTo c + GroupChat g -> groupActiveTo g + _ -> "" + +contactActiveTo :: Contact -> String +contactActiveTo c = T.unpack $ "@" <> viewContactName c <> " " + +groupActiveTo :: GroupInfo -> String +groupActiveTo g = T.unpack $ "#" <> viewGroupName g <> " " + +setActiveChat :: ChatTerminal -> ChatInfo c -> IO () +setActiveChat t = setActive t . chatInfoActiveTo + +setActiveContact :: ChatTerminal -> Contact -> IO () +setActiveContact t = setActive t . contactActiveTo + +setActiveGroup :: ChatTerminal -> GroupInfo -> IO () +setActiveGroup t = setActive t . groupActiveTo + +setActive :: ChatTerminal -> String -> IO () +setActive ChatTerminal {activeTo} to = atomically $ writeTVar activeTo to + +unsetActiveContact :: ChatTerminal -> Contact -> IO () +unsetActiveContact t = unsetActive t . contactActiveTo + +unsetActiveGroup :: ChatTerminal -> GroupInfo -> IO () +unsetActiveGroup t = unsetActive t . groupActiveTo + +unsetActive :: ChatTerminal -> String -> IO () +unsetActive ChatTerminal {activeTo} to' = atomically $ modifyTVar activeTo unset + where + unset to = if to == to' then "" else to + +whenCurrUser :: ChatController -> User -> IO () -> IO () +whenCurrUser cc u a = do + u_ <- readTVarIO $ currentUser cc + when (sameUser u u_) a + where + sameUser User {userId = uId} = maybe False $ \User {userId} -> userId == uId + printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> ChatResponse -> IO () printRespToTerminal ct cc liveItems r = responseString cc liveItems r >>= printToTerminal ct diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 864ebd7227..de56baad92 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1424,8 +1424,6 @@ serializeIntroStatus = \case GMIntroToConnected -> "to-con" GMIntroConnected -> "con" -data Notification = Notification {title :: Text, text :: Text} - textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index bb5e854cd2..f60b7cd82f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -103,7 +103,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts tz <> viewItemReactions item - CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems + CRChatItems u _ chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] CRChatItemStatusUpdated u ci -> ttyUser u $ viewChatItemStatusUpdated ci ts tz testView showReceipts diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index ae2d67c7f0..ed0b9e069a 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -26,7 +26,7 @@ withBroadcastBot :: BroadcastBotOpts -> IO () -> IO () withBroadcastBot opts test = bracket (forkIO bot) killThread (\_ -> threadDelay 500000 >> test) where - bot = simplexChatCore testCfg (mkChatOpts opts) Nothing $ broadcastBot opts + bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", image = Nothing, contactLink = Nothing, preferences = Nothing} diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 0e315190c5..3e1c32a6f7 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -827,7 +827,7 @@ runDirectory cfg opts@DirectoryOpts {directoryLog} action = do threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) where - bot st = simplexChatCore cfg (mkChatOpts opts) Nothing $ directoryService st opts + bot st = simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 7da5263253..aae3b5c4c7 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -161,8 +161,8 @@ startTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefi startTestChat_ :: ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC startTestChat_ db cfg opts user = do t <- withVirtualTerminal termSettings pure - ct <- newChatTerminal t - cc <- newChatController db (Just user) cfg opts Nothing -- no notifications + ct <- newChatTerminal t opts + cc <- newChatController db (Just user) cfg opts chatAsync <- async . runSimplexChat opts user cc . const $ runChatTerminal ct atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO From b03fe183bb2dece22e9d7cbdfada70e85e9a774c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:26:44 +0400 Subject: [PATCH 056/294] tests: modify testMemberContactInvitedConnectionReplaced to not rely on chat item order, print output (#3198) --- tests/ChatClient.hs | 2 ++ tests/ChatTests/Groups.hs | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index aae3b5c4c7..fae460e908 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -210,6 +210,8 @@ withTestChatOpts tmp = withTestChatCfgOpts tmp testCfg withTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a withTestChatCfgOpts tmp cfg opts dbPrefix = bracket (startTestChat tmp cfg opts dbPrefix) (\cc -> cc > stopTestChat cc) +-- enable output for specific chat controller, use like this: +-- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ... withTestOutput :: HasCallStack => TestCC -> (HasCallStack => TestCC -> IO a) -> IO a withTestOutput cc runTest = runTest cc {printOutput = True} diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 997beec4ea..6578df84c0 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3252,9 +3252,9 @@ testMemberContactProhibitedRepeatInv = testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \b -> withTestOutput b $ \bob -> do + withNewTestChat tmp "cath" cathProfile $ \c -> withTestOutput c $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -3277,7 +3277,9 @@ testMemberContactInvitedConnectionReplaced tmp = do (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] <> chatFeatures) + bob ##> "/_get chat @2 count=100" + items <- chat <$> getTermLine bob + items `shouldContain` [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 From bca9473d7704b3476452144b58a96d7ffb9e8728 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 19:10:38 +0100 Subject: [PATCH 057/294] core: settings to hide member messages, to show only reply (and mention) notifications (#3190) * core: settings to hide member messages, to show only reply (and mention) notifications * change type for showMessages * commands for member settings * member and notification settings * test * take member settings into account when showing messages and notifications * fix to show sent messages * store blocked items * types * rename to MFMentions --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 132 ++++++++++-------- src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Messages.hs | 37 +++-- src/Simplex/Chat/Messages/CIContent.hs | 14 +- .../Migrations/M20231010_member_settings.hs | 18 +++ src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Protocol.hs | 5 + src/Simplex/Chat/Store/Connections.hs | 8 +- src/Simplex/Chat/Store/Groups.hs | 51 ++++--- src/Simplex/Chat/Store/Messages.hs | 96 ++++++++----- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Shared.hs | 6 +- src/Simplex/Chat/Terminal/Output.hs | 36 ++--- src/Simplex/Chat/Types.hs | 62 +++++++- src/Simplex/Chat/View.hs | 121 ++++++++++------ tests/ChatTests/Direct.hs | 71 +++++++++- tests/ChatTests/Groups.hs | 1 - 18 files changed, 441 insertions(+), 227 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231010_member_settings.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 5a84a1cde8..b431b0ddf3 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -116,6 +116,7 @@ library Simplex.Chat.Migrations.M20230926_contact_status Simplex.Chat.Migrations.M20231002_conn_initiated Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash + Simplex.Chat.Migrations.M20231010_member_settings Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5e2a231c97..b7421d9366 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -800,7 +800,7 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of CTDirect -> do - (ct, ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}})) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteDirectCI user ct ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do @@ -812,7 +812,7 @@ processChatCommand = \case (CIDMBroadcast, _, _, _) -> throwChatError CEInvalidChatItemDelete CTGroup -> do Group gInfo ms <- withStore $ \db -> getGroup db user chatId - ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> getGroupChatItem db user chatId itemId + CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}} <- withStore $ \db -> getGroupChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteGroupCI user gInfo ci True False Nothing =<< liftIO getCurrentTime (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do @@ -824,7 +824,7 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteMemberChatItem gId mId itemId -> withUser $ \user -> withChatLock "deleteChatItem" $ do Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db user gId - ci@(CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) <- withStore $ \db -> getGroupChatItem db user gId itemId + CChatItem _ ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}} <- withStore $ \db -> getGroupChatItem db user gId itemId case (chatDir, itemSharedMsgId) of (CIGroupRcv GroupMember {groupMemberId, memberRole, memberId}, Just itemSharedMId) -> do when (groupMemberId /= mId) $ throwChatError CEInvalidChatItemDelete @@ -1178,7 +1178,7 @@ processChatCommand = \case ct <- getContact db user chatId liftIO $ updateContactSettings db user chatId chatSettings pure ct - withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings) + withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (chatHasNtfs chatSettings) ok user CTGroup -> do ms <- withStore $ \db -> do @@ -1186,9 +1186,17 @@ processChatCommand = \case liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchChatError` (toView . CRChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` (toView . CRChatError (Just user)) ok user _ -> pure $ chatCmdError (Just user) "not supported" + APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do + m <- withStore $ \db -> do + liftIO $ updateGroupMemberSettings db user gId gMemberId settings + getGroupMember db user gId gMemberId + when (memberActive m) $ forM_ (memberConnId m) $ \connId -> do + let ntfOn = showMessages $ memberSettings m + withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) + ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId @@ -1283,6 +1291,11 @@ processChatCommand = \case _ -> throwChatError CEGroupMemberNotActive SetShowMessages cName ntfOn -> updateChatSettings cName (\cs -> cs {enableNtfs = ntfOn}) SetSendReceipts cName rcptsOn_ -> updateChatSettings cName (\cs -> cs {sendRcpts = rcptsOn_}) + SetShowMemberMessages gName mName showMessages -> withUser $ \user -> do + (gId, mId) <- getGroupAndMemberId user gName mName + m <- withStore $ \db -> getGroupMember db user gId mId + let settings = (memberSettings m) {showMessages} + processChatCommand $ APISetMemberSettings gId mId settings ContactInfo cName -> withContactName cName APIContactInfo ShowGroupInfo gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName @@ -2073,7 +2086,7 @@ processChatCommand = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - delGroupChatItem :: User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> m ChatResponse + delGroupChatItem :: MsgDirectionI d => User -> GroupInfo -> ChatItem 'CTGroup d -> MessageId -> Maybe GroupMember -> m ChatResponse delGroupChatItem user gInfo ci msgId byGroupMember = do deletedTs <- liftIO getCurrentTime if groupFeatureAllowed SGFFullDelete gInfo @@ -2813,10 +2826,10 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do waitChatStarted case cType of CTDirect -> do - (ct, ci) <- withStoreCtx (Just "deleteTimedItem, getContact ...") $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId deleteDirectCI user ct ci True True >>= toView CTGroup -> do - (gInfo, ci) <- withStoreCtx (Just "deleteTimedItem, getGroupInfo ...") $ \db -> (,) <$> getGroupInfo db user chatId <*> getGroupChatItem db user chatId itemId + (gInfo, CChatItem _ ci) <- withStore $ \db -> (,) <$> getGroupInfo db user chatId <*> getGroupChatItem db user chatId itemId deletedTs <- liftIO getCurrentTime deleteGroupCI user gInfo ci True True Nothing deletedTs >>= toView _ -> toView . CRChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" @@ -3335,7 +3348,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do updateGroupMemberStatus db userId membership GSMemConnected -- possible improvement: check for each pending message, requires keeping track of connection state unless (connDisabled conn) $ sendPendingGroupMessages user m conn - withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ enableNtfs chatSettings + withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ chatHasNtfs chatSettings case memberCategory m of GCHostMember -> do toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} @@ -3932,7 +3945,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do deleteRcvChatItem `catchCINotFound` (toView . CRChatItemDeletedNotFound user ct) where deleteRcvChatItem = do - ci@(CChatItem msgDir _) <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId + CChatItem msgDir ci <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId case msgDir of SMDRcv -> if featureAllowed SCFFullDelete forContact ct @@ -4013,21 +4026,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createItem timed_ live | groupFeatureAllowed SGFFullDelete gInfo = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta CIRcvModerated Nothing timed_ False - ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo (CChatItem SMDRcv ci) moderator moderatedAt - toView $ CRNewChatItem user ci' + ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt + toView $ CRNewChatItem user $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci' | otherwise = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent content) (snd <$> file_) timed_ False - cr <- markGroupCIDeleted user gInfo (CChatItem SMDRcv ci) createdByMsgId False (Just moderator) moderatedAt - toView cr + toView =<< markGroupCIDeleted user gInfo ci createdByMsgId False (Just moderator) moderatedAt createItem timed_ live = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live - autoAcceptFile file_ + when (showMessages $ memberSettings m) $ autoAcceptFile file_ newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live + ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ - groupMsgToView gInfo m ci {reactions} msgMeta + groupMsgToView gInfo m ci' {reactions} msgMeta groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = @@ -4039,7 +4052,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc - updateGroupChatItem db user groupId ci content live Nothing + ci' <- updateGroupChatItem db user groupId ci content live Nothing + blockedMember m ci' $ markGroupChatItemBlocked db user gInfo ci' toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') where MsgMeta {broker = (_, brokerTs)} = msgMeta @@ -4068,7 +4082,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do groupMessageDelete gInfo@GroupInfo {groupId, membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do let msgMemberId = fromMaybe memberId sndMemberId_ withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user groupId msgMemberId sharedMsgId) >>= \case - Right ci@(CChatItem _ ChatItem {chatDir}) -> case chatDir of + Right (CChatItem _ ci@ChatItem {chatDir}) -> case chatDir of CIGroupRcv mem | sameMemberId memberId mem && msgMemberId == memberId -> delete ci Nothing >>= toView | otherwise -> deleteMsg mem ci @@ -4078,7 +4092,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | senderRole < GRAdmin -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e | otherwise -> withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs where - deleteMsg :: GroupMember -> CChatItem 'CTGroup -> m () + deleteMsg :: MsgDirectionI d => GroupMember -> ChatItem 'CTGroup d -> m () deleteMsg mem ci = case sndMemberId_ of Just sndMemberId | sameMemberId sndMemberId mem -> checkRole mem $ delete ci (Just m) >>= toView @@ -4088,6 +4102,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | senderRole < GRAdmin || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" | otherwise = a + delete :: MsgDirectionI d => ChatItem 'CTGroup d -> Maybe GroupMember -> m ChatResponse delete ci byGroupMember | groupFeatureAllowed SGFFullDelete gInfo = deleteGroupCI user gInfo ci False False byGroupMember brokerTs | otherwise = markGroupCIDeleted user gInfo ci msgId False byGroupMember brokerTs @@ -4113,7 +4128,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False - groupMsgToView gInfo m ci msgMeta + ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci + groupMsgToView gInfo m ci' msgMeta + + blockedMember :: Monad m' => GroupMember -> ChatItem c d -> m' (ChatItem c d) -> m' (ChatItem c d) + blockedMember m ci blockedCI + | showMessages (memberSettings m) = pure ci + | otherwise = blockedCI receiveInlineMode :: FileInvitation -> Maybe MsgContent -> Integer -> m (Maybe InlineFileMode) receiveInlineMode FileInvitation {fileSize, fileInline, fileDescr} mc_ chSize = case (fileInline, fileDescr) of @@ -4632,7 +4653,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRJoinedGroupMemberConnecting user gInfo m newMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () - xGrpMemIntro gInfo@GroupInfo {chatSettings = ChatSettings {enableNtfs}} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do + xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do case memberCategory m of GCHostMember -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -4652,7 +4673,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" where - createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation subMode + createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> m () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do @@ -4675,7 +4696,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo toMember <- case find (sameMemberId memId) members of @@ -4690,8 +4711,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- [incognito] send membership incognito profile, create direct connection as incognito dm <- directMessage $ XGrpMemInfo membership.memberId (fromLocalProfile $ memberProfile membership) -- [async agent commands] no continuation needed, but commands should be asynchronous for stability - groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm subMode - directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user enableNtfs dcr dm subMode + groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode + directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId subMode @@ -5215,20 +5236,22 @@ mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs cur meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} -deleteDirectCI :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> Bool -> Bool -> m ChatResponse -deleteDirectCI user ct ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed = do +deleteDirectCI :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> m ChatResponse +deleteDirectCI user ct ci@ChatItem {file} byUser timed = do deleteCIFile user file withStoreCtx' (Just "deleteDirectCI, deleteDirectChatItem") $ \db -> deleteDirectChatItem db user ct ci - pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) Nothing byUser timed + pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDirection (DirectChat ct) ci) Nothing byUser timed -deleteGroupCI :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse -deleteGroupCI user gInfo ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed byGroupMember_ deletedTs = do +deleteGroupCI :: (ChatMonad m, MsgDirectionI d) => User -> GroupInfo -> ChatItem 'CTGroup d -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse +deleteGroupCI user gInfo ci@ChatItem {file} byUser timed byGroupMember_ deletedTs = do deleteCIFile user file toCi <- withStoreCtx' (Just "deleteGroupCI, deleteGroupChatItem ...") $ \db -> case byGroupMember_ of Nothing -> deleteGroupChatItem db user gInfo ci $> Nothing Just m -> Just <$> updateGroupChatItemModerated db user gInfo ci m deletedTs - pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi byUser timed + pure $ CRChatItemDeleted user (gItem ci) (gItem <$> toCi) byUser timed + where + gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo) deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () deleteCIFile user file_ = @@ -5236,25 +5259,21 @@ deleteCIFile user file_ = fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True deleteAgentConnectionsAsync user fileAgentConnIds -markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> UTCTime -> m ChatResponse -markDirectCIDeleted user ct@Contact {contactId} ci@(CChatItem _ ChatItem {file}) msgId byUser deletedTs = do +markDirectCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> MessageId -> Bool -> UTCTime -> m ChatResponse +markDirectCIDeleted user ct ci@ChatItem {file} msgId byUser deletedTs = do cancelCIFile user file - toCi <- withStore $ \db -> do - liftIO $ markDirectChatItemDeleted db user ct ci msgId deletedTs - getDirectChatItem db user contactId (cchatItemId ci) - pure $ CRChatItemDeleted user (ctItem ci) (Just $ ctItem toCi) byUser False + ci' <- withStore' $ \db -> markDirectChatItemDeleted db user ct ci msgId deletedTs + pure $ CRChatItemDeleted user (ctItem ci) (Just $ ctItem ci') byUser False where - ctItem (CChatItem msgDir ci') = AChatItem SCTDirect msgDir (DirectChat ct) ci' + ctItem = AChatItem SCTDirect msgDirection (DirectChat ct) -markGroupCIDeleted :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse -markGroupCIDeleted user gInfo@GroupInfo {groupId} ci@(CChatItem _ ChatItem {file}) msgId byUser byGroupMember_ deletedTs = do +markGroupCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> GroupInfo -> ChatItem 'CTGroup d -> MessageId -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse +markGroupCIDeleted user gInfo ci@ChatItem {file} msgId byUser byGroupMember_ deletedTs = do cancelCIFile user file - toCi <- withStore $ \db -> do - liftIO $ markGroupChatItemDeleted db user gInfo ci msgId byGroupMember_ deletedTs - getGroupChatItem db user groupId (cchatItemId ci) - pure $ CRChatItemDeleted user (gItem ci) (Just $ gItem toCi) byUser False + ci' <- withStore' $ \db -> markGroupChatItemDeleted db user gInfo ci msgId byGroupMember_ deletedTs + pure $ CRChatItemDeleted user (gItem ci) (Just $ gItem ci') byUser False where - gItem (CChatItem msgDir ci') = AChatItem SCTGroup msgDir (GroupChat gInfo) ci' + gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo) cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () cancelCIFile user file_ = @@ -5440,21 +5459,6 @@ getCreateActiveUser st testView = do getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine -userNtf :: User -> Bool -userNtf User {showNtfs, activeUser} = showNtfs || activeUser - -chatNtf :: User -> ChatInfo c -> Bool -chatNtf user = \case - DirectChat ct -> contactNtf user ct - GroupChat g -> groupNtf user g - _ -> False - -contactNtf :: User -> Contact -> Bool -contactNtf user Contact {chatSettings} = userNtf user && enableNtfs chatSettings - -groupNtf :: User -> GroupInfo -> Bool -groupNtf user GroupInfo {chatSettings} = userNtf user && enableNtfs chatSettings - withUser' :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser' action = asks currentUser @@ -5492,9 +5496,12 @@ withAgent action = chatCommandP :: Parser ChatCommand chatCommandP = choice - [ "/mute " *> ((`SetShowMessages` False) <$> chatNameP), - "/unmute " *> ((`SetShowMessages` True) <$> chatNameP), + [ "/mute " *> ((`SetShowMessages` MFNone) <$> chatNameP), + "/unmute " *> ((`SetShowMessages` MFAll) <$> chatNameP), + "/unmute mentions " *> ((`SetShowMessages` MFMentions) <$> chatNameP), "/receipts " *> (SetSendReceipts <$> chatNameP <* " " <*> ((Just <$> onOffP) <|> ("default" $> Nothing))), + "/block #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), + "/unblock #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), "/_create user " *> (CreateActiveUser <$> jsonP), "/create user " *> (CreateActiveUser <$> newUserP), "/users" $> ListUsers, @@ -5598,6 +5605,7 @@ chatCommandP = ("/network" <|> "/net") $> APIGetNetworkConfig, "/reconnect" $> ReconnectAllServers, "/_settings " *> (APISetChatSettings <$> chatRefP <* A.space <*> jsonP), + "/_member settings #" *> (APISetMemberSettings <$> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), "/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal), "/_info #" *> (APIGroupInfo <$> A.decimal), "/_info @" *> (APIContactInfo <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d97d59c5c5..af9f34d2d4 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -288,6 +288,7 @@ data ChatCommand | APIGetNetworkConfig | ReconnectAllServers | APISetChatSettings ChatRef ChatSettings + | APISetMemberSettings GroupId GroupMemberId GroupMemberSettings | APIContactInfo ContactId | APIGroupInfo GroupId | APIGroupMemberInfo GroupId GroupMemberId @@ -303,8 +304,9 @@ data ChatCommand | APIVerifyGroupMember GroupId GroupMemberId (Maybe Text) | APIEnableContact ContactId | APIEnableGroupMember GroupId GroupMemberId - | SetShowMessages ChatName Bool + | SetShowMessages ChatName MsgFilter | SetSendReceipts ChatName (Maybe Bool) + | SetShowMemberMessages GroupName ContactName Bool | ContactInfo ContactName | ShowGroupInfo GroupName | GroupMemberInfo GroupName ContactName diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 3831fad03e..22506218aa 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -150,6 +150,19 @@ instance MsgDirectionI d => ToJSON (ChatItem c d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +isReference :: ChatItem c d -> Bool +isReference ChatItem {chatDir, quotedItem} = case chatDir of + CIDirectRcv -> userItem quotedItem + CIGroupRcv _ -> userItem quotedItem + _ -> False + where + userItem = \case + Nothing -> False + Just CIQuote {chatDir = cd} -> case cd of + CIQDirectSnd -> True + CIQGroupSnd -> True + _ -> False + data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectSnd :: CIDirection 'CTDirect 'MDSnd CIDirectRcv :: CIDirection 'CTDirect 'MDRcv @@ -220,26 +233,6 @@ ciReactionAllowed :: ChatItem c d -> Bool ciReactionAllowed ChatItem {meta = CIMeta {itemDeleted = Just _}} = False ciReactionAllowed ChatItem {content} = isJust $ ciMsgContent content -data CIDeletedState = CIDeletedState - { markedDeleted :: Bool, - deletedByMember :: Maybe GroupMember - } - deriving (Show, Eq) - -chatItemDeletedState :: ChatItem c d -> Maybe CIDeletedState -chatItemDeletedState ChatItem {meta = CIMeta {itemDeleted}, content} = - ciDeletedToDeletedState <$> itemDeleted - where - ciDeletedToDeletedState cid = - case content of - CISndModerated -> CIDeletedState {markedDeleted = False, deletedByMember = byMember cid} - CIRcvModerated -> CIDeletedState {markedDeleted = False, deletedByMember = byMember cid} - _ -> CIDeletedState {markedDeleted = True, deletedByMember = byMember cid} - byMember :: CIDeleted c -> Maybe GroupMember - byMember = \case - CIModerated _ m -> Just m - CIDeleted _ -> Nothing - data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv @@ -929,6 +922,7 @@ checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of data CIDeleted (c :: ChatType) where CIDeleted :: Maybe UTCTime -> CIDeleted c + CIBlocked :: Maybe UTCTime -> CIDeleted c CIModerated :: Maybe UTCTime -> GroupMember -> CIDeleted 'CTGroup deriving instance Show (CIDeleted c) @@ -939,6 +933,7 @@ instance ToJSON (CIDeleted d) where data JSONCIDeleted = JCIDDeleted {deletedTs :: Maybe UTCTime} + | JCIBlocked {deletedTs :: Maybe UTCTime} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} deriving (Show, Generic) @@ -949,11 +944,13 @@ instance ToJSON JSONCIDeleted where jsonCIDeleted :: CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case CIDeleted ts -> JCIDDeleted ts + CIBlocked ts -> JCIBlocked ts CIModerated ts m -> JCIDModerated ts m itemDeletedTs :: CIDeleted d -> Maybe UTCTime itemDeletedTs = \case CIDeleted ts -> ts + CIBlocked ts -> ts CIModerated ts _ -> ts data ChatItemInfo = ChatItemInfo diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 9abc8e4644..d3cdbcf3e4 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -19,12 +19,8 @@ import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Type.Equality -import Data.Typeable (Typeable) import Data.Word (Word32) -import Database.SQLite.Simple (ResultError (..), SQLData (..)) -import Database.SQLite.Simple.FromField (Field, FromField (..), returnError) -import Database.SQLite.Simple.Internal (Field (..)) -import Database.SQLite.Simple.Ok +import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Protocol @@ -50,14 +46,6 @@ instance FromField AMsgDirection where fromField = fromIntField_ $ fmap fromMsgD instance ToField MsgDirection where toField = toField . msgDirectionInt -fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a -fromIntField_ fromInt = \case - f@(Field (SQLInteger i) _) -> - case fromInt i of - Just x -> Ok x - _ -> returnError ConversionFailed f ("invalid integer: " <> show i) - f -> returnError ConversionFailed f "expecting SQLInteger column type" - data SMsgDirection (d :: MsgDirection) where SMDRcv :: SMsgDirection 'MDRcv SMDSnd :: SMsgDirection 'MDSnd diff --git a/src/Simplex/Chat/Migrations/M20231010_member_settings.hs b/src/Simplex/Chat/Migrations/M20231010_member_settings.hs new file mode 100644 index 0000000000..e31203e572 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231010_member_settings.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231010_member_settings where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231010_member_settings :: Query +m20231010_member_settings = + [sql| +ALTER TABLE group_members ADD COLUMN show_messages INTEGER NOT NULL DEFAULT 1; +|] + +down_m20231010_member_settings :: Query +down_m20231010_member_settings = + [sql| +ALTER TABLE group_members DROP COLUMN show_messages; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 542acbbebd..7308ef89ff 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -145,6 +145,7 @@ CREATE TABLE group_members( created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL), member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, + show_messages INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index bbdddf8ce0..0f69efe7c0 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -378,6 +378,11 @@ mcExtMsgContent = \case MCQuote _ c -> c MCForward c -> c +isQuote :: MsgContainer -> Bool +isQuote = \case + MCQuote {} -> True + _ -> False + data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData, content :: Maybe LinkContent} deriving (Eq, Show, Generic) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index c9e846a81f..3ef77cbb65 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -79,10 +79,10 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact + toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" @@ -97,11 +97,11 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 20fb8c7217..30e45a82dc 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -92,6 +92,7 @@ module Simplex.Chat.Store.Groups associateContactWithMemberRecord, deleteOldProbes, updateGroupSettings, + updateGroupMemberSettings, getXGrpMemIntroContDirect, getXGrpMemIntroContGroup, getHostConnId, @@ -131,30 +132,31 @@ import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow -type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) -type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs) :. userMemberRow) = let membership = toGroupMember userContactId userMemberRow - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} in GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} + memberSettings = GroupMemberSettings {showMessages} invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing in GroupMember {..} toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -250,11 +252,11 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, @@ -300,7 +302,7 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs - let chatSettings = ChatSettings {enableNtfs = True, sendRcpts = Nothing, favorite = False} + let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs} -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -345,7 +347,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo insertedRowId db GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs - let chatSettings = ChatSettings {enableNtfs = True, sendRcpts = Nothing, favorite = False} + let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure (GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs}, groupMemberId) getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId @@ -369,6 +371,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me memberRole, memberCategory, memberStatus, + memberSettings = defaultMemberSettings, invitedBy, localDisplayName, memberProfile, @@ -493,7 +496,7 @@ getUserGroupDetails db User {userId, userContactId} _contactId_ search_ = db [sql| SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, - mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, + mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp USING (group_profile_id) @@ -558,7 +561,7 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, @@ -665,6 +668,7 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con memberRole, memberCategory = GCInviteeMember, memberStatus = GSMemInvited, + memberSettings = defaultMemberSettings, invitedBy = IBUser, localDisplayName, memberProfile = profile, @@ -815,7 +819,8 @@ createNewMember_ |] (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) groupMemberId <- insertedRowId db - pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn} + let memberSettings = defaultMemberSettings + pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, memberSettings, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn} checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) checkGroupMemberHasItems db User {userId} GroupMember {groupMemberId, groupId} = @@ -1013,11 +1018,11 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, @@ -1106,7 +1111,7 @@ getGroupInfo db User {userId, userContactId} groupId = g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -1502,6 +1507,18 @@ updateGroupSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateGroupSettings db User {userId} groupId ChatSettings {enableNtfs, sendRcpts, favorite} = DB.execute db "UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, sendRcpts, favorite, userId, groupId) +updateGroupMemberSettings :: DB.Connection -> User -> GroupId -> GroupMemberId -> GroupMemberSettings -> IO () +updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {showMessages} = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET show_messages = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + |] + (showMessages, currentTs, userId, gId, gMemberId) + getXGrpMemIntroContDirect :: DB.Connection -> User -> Contact -> IO (Maybe (Int64, XGrpMemIntroCont)) getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do fmap join . maybeFirstRow toCont $ diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 9ad0e8edc8..0f9abaa465 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -4,6 +4,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -50,6 +51,7 @@ module Simplex.Chat.Store.Messages deleteGroupChatItem, updateGroupChatItemModerated, markGroupChatItemDeleted, + markGroupChatItemBlocked, updateDirectChatItemsRead, getDirectUnreadTimedItems, setDirectChatItemDeleteAt, @@ -438,7 +440,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe SELECT i.chat_item_id, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -548,7 +550,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat, @@ -558,17 +560,17 @@ getGroupChatPreviews_ db User {userId, userContactId} = do f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -962,9 +964,9 @@ type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) -type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) :. (Bool, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow +type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) :. (Int, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe AMsgDirection, Maybe Text, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId) :. (Maybe Bool, Maybe UTCTime, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe AMsgDirection, Maybe Text, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId) :. (Maybe Int, Maybe UTCTime, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) @@ -1007,7 +1009,9 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = - let itemDeleted' = if itemDeleted then Just (CIDeleted @'CTDirect deletedTs) else Nothing + let itemDeleted' = case itemDeleted of + DBCINotDeleted -> Nothing + _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = fromMaybe False itemEdited in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt ciTimed :: Maybe CITimed @@ -1063,10 +1067,10 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = - let itemDeleted' = - if itemDeleted - then Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) - else Nothing + let itemDeleted' = case itemDeleted of + DBCINotDeleted -> Nothing + DBCIBlocked -> Just (CIBlocked @'CTGroup deletedTs) + _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt ciTimed :: Maybe CITimed @@ -1225,8 +1229,8 @@ createChatItemVersion db itemId itemVersionTs msgContent = |] (itemId, toMCText msgContent, itemVersionTs) -deleteDirectChatItem :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> IO () -deleteDirectChatItem db User {userId} Contact {contactId} (CChatItem _ ci) = do +deleteDirectChatItem :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect d -> IO () +deleteDirectChatItem db User {userId} Contact {contactId} ci = do let itemId = chatItemId' ci deleteChatItemMessages_ db itemId deleteChatItemVersions_ db itemId @@ -1257,8 +1261,8 @@ deleteChatItemVersions_ :: DB.Connection -> ChatItemId -> IO () deleteChatItemVersions_ db itemId = DB.execute db "DELETE FROM chat_item_versions WHERE chat_item_id = ?" (Only itemId) -markDirectChatItemDeleted :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> MessageId -> UTCTime -> IO () -markDirectChatItemDeleted db User {userId} Contact {contactId} (CChatItem _ ci) msgId deletedTs = do +markDirectChatItemDeleted :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect d -> MessageId -> UTCTime -> IO (ChatItem 'CTDirect d) +markDirectChatItemDeleted db User {userId} Contact {contactId} ci@ChatItem {meta} msgId deletedTs = do currentTs <- liftIO getCurrentTime let itemId = chatItemId' ci insertChatItemMessage_ db itemId msgId currentTs @@ -1266,10 +1270,11 @@ markDirectChatItemDeleted db User {userId} Contact {contactId} (CChatItem _ ci) db [sql| UPDATE chat_items - SET item_deleted = 1, item_deleted_ts = ?, updated_at = ? + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? |] - (deletedTs, currentTs, userId, contactId, itemId) + (DBCIDeleted, deletedTs, currentTs, userId, contactId, itemId) + pure ci {meta = meta {itemDeleted = Just $ CIDeleted $ Just deletedTs}} getDirectChatItemBySharedMsgId :: DB.Connection -> User -> ContactId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTDirect) getDirectChatItemBySharedMsgId db user@User {userId} contactId sharedMsgId = do @@ -1380,8 +1385,8 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = ((content, itemText, itemStatus, itemDeleted', itemDeletedTs', itemEdited, itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt -deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO () -deleteGroupChatItem db User {userId} g@GroupInfo {groupId} (CChatItem _ ci) = do +deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () +deleteGroupChatItem db User {userId} g@GroupInfo {groupId} ci = do let itemId = chatItemId' ci deleteChatItemMessages_ db itemId deleteChatItemVersions_ db itemId @@ -1394,10 +1399,10 @@ deleteGroupChatItem db User {userId} g@GroupInfo {groupId} (CChatItem _ ci) = do |] (userId, groupId, itemId) -updateGroupChatItemModerated :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> GroupMember -> UTCTime -> IO AChatItem -updateGroupChatItemModerated db User {userId} gInfo@GroupInfo {groupId} (CChatItem msgDir ci) m@GroupMember {groupMemberId} deletedTs = do +updateGroupChatItemModerated :: forall d. MsgDirectionI d => DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> GroupMember -> UTCTime -> IO (ChatItem 'CTGroup d) +updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMember {groupMemberId} deletedTs = do currentTs <- getCurrentTime - let toContent = msgDirToModeratedContent_ msgDir + let toContent = msgDirToModeratedContent_ $ msgDirection @d toText = ciModeratedText itemId = chatItemId' ci deleteChatItemMessages_ db itemId @@ -1411,24 +1416,47 @@ updateGroupChatItemModerated db User {userId} gInfo@GroupInfo {groupId} (CChatIt WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) - pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) (ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just currentTs) m), editable = False}, formattedText = Nothing}) + pure $ ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just currentTs) m), editable = False}, formattedText = Nothing} -markGroupChatItemDeleted :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> UTCTime -> IO () -markGroupChatItemDeleted db User {userId} GroupInfo {groupId} (CChatItem _ ci) msgId byGroupMember_ deletedTs = do +pattern DBCINotDeleted :: Int +pattern DBCINotDeleted = 0 + +pattern DBCIDeleted :: Int +pattern DBCIDeleted = 1 + +pattern DBCIBlocked :: Int +pattern DBCIBlocked = 2 + +markGroupChatItemDeleted :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> MessageId -> Maybe GroupMember -> UTCTime -> IO (ChatItem 'CTGroup d) +markGroupChatItemDeleted db User {userId} GroupInfo {groupId} ci@ChatItem {meta} msgId byGroupMember_ deletedTs = do currentTs <- liftIO getCurrentTime let itemId = chatItemId' ci - deletedByGroupMemberId = case byGroupMember_ of - Just GroupMember {groupMemberId} -> Just groupMemberId - _ -> Nothing + (deletedByGroupMemberId, itemDeleted) = case byGroupMember_ of + Just m@GroupMember {groupMemberId} -> (Just groupMemberId, Just $ CIModerated (Just deletedTs) m) + _ -> (Nothing, Just $ CIDeleted @'CTGroup (Just deletedTs)) insertChatItemMessage_ db itemId msgId currentTs DB.execute db [sql| UPDATE chat_items - SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] - (deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId) + (DBCIDeleted, deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId) + pure ci {meta = meta {itemDeleted}} + +markGroupChatItemBlocked :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup 'MDRcv -> IO (ChatItem 'CTGroup 'MDRcv) +markGroupChatItemBlocked db User {userId} GroupInfo {groupId} ci@ChatItem {meta} = do + deletedTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + |] + (DBCIBlocked, deletedTs, deletedTs, userId, groupId, chatItemId' ci) + pure ci {meta = meta {itemDeleted = Just $ CIBlocked $ Just deletedTs}} getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do @@ -1486,17 +1514,17 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 5c44b8cded..60783f3664 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -84,6 +84,7 @@ import Simplex.Chat.Migrations.M20230914_member_probes import Simplex.Chat.Migrations.M20230926_contact_status import Simplex.Chat.Migrations.M20231002_conn_initiated import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash +import Simplex.Chat.Migrations.M20231010_member_settings import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -167,7 +168,8 @@ schemaMigrations = ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated), - ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash) + ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash), + ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 3ff765b75d..2a90b54d7f 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -241,20 +241,20 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] [":user_id" := userId, ":profile_id" := profileId] -type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) +type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) toContact :: User -> ContactRow :. ConnectionRow -> Contact toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toConnection connRow - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} in case toMaybeConnection connRow of Just activeConn -> let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 556e4f792e..a45390e8c3 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -19,7 +19,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) -import Simplex.Chat (processChatCommand, chatNtf, contactNtf, groupNtf, userNtf) +import Simplex.Chat (processChatCommand) import Simplex.Chat.Controller import Simplex.Chat.Markdown import Simplex.Chat.Messages @@ -28,7 +28,7 @@ import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..), msgContentText) import Simplex.Chat.Styled import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) -import Simplex.Chat.Types (Contact, GroupInfo (..), User (..), UserContactRequest (..)) +import Simplex.Chat.Types import Simplex.Chat.View import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Encoding.String @@ -140,8 +140,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d forever $ do (_, r) <- atomically $ readTBQueue outputQ case r of - CRNewChatItem _ ci -> markChatItemRead ci - CRChatItemUpdated _ ci -> markChatItemRead ci + CRNewChatItem u ci -> markChatItemRead u ci + CRChatItemUpdated u ci -> markChatItemRead u ci _ -> pure () let printResp = case logFilePath of Just path -> if logResponseToFile r then logResponse path else printToTerminal ct @@ -150,10 +150,10 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d responseString cc liveItems r >>= printResp responseNotification ct cc r where - markChatItemRead (AChatItem _ _ chat item@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = - case (muted chat chatDir, itemStatus) of - (False, CISRcvNew) -> do - let itemId = chatItemId' item + markChatItemRead u (AChatItem _ _ chat ci@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = + case (chatDirNtf u chat chatDir (isReference ci), itemStatus) of + (True, CISRcvNew) -> do + let itemId = chatItemId' ci chatRef = chatInfoToRef chat void $ runReaderT (runExceptT $ processChatCommand (APIChatRead chatRef (Just (itemId, itemId)))) cc _ -> pure () @@ -161,8 +161,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () responseNotification t@ChatTerminal {sendNotification} cc = \case - CRNewChatItem u (AChatItem _ SMDRcv cInfo ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) -> - when (chatNtf u cInfo) $ do + CRNewChatItem u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) -> + when (chatDirNtf u cInfo chatDir $ isReference ci) $ do whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) @@ -170,26 +170,26 @@ responseNotification t@ChatTerminal {sendNotification} cc = \case _ -> pure () where text = msgText mc formattedText - CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ChatItem {content = CIRcvMsgContent _}) -> - whenCurrUser cc u $ when (chatNtf u cInfo) $ setActiveChat t cInfo - CRContactConnected u ct _ -> when (contactNtf u ct) $ do + CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> + whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isReference ci) $ setActiveChat t cInfo + CRContactConnected u ct _ -> when (contactNtf u ct False) $ do whenCurrUser cc u $ setActiveContact t ct sendNtf (viewContactName ct <> "> ", "connected") CRContactAnotherClient u ct -> do whenCurrUser cc u $ unsetActiveContact t ct - when (contactNtf u ct) $ sendNtf (viewContactName ct <> "> ", "connected to another client") + when (contactNtf u ct False) $ sendNtf (viewContactName ct <> "> ", "connected to another client") CRContactsDisconnected srv _ -> serverNtf srv "disconnected" CRContactsSubscribed srv _ -> serverNtf srv "connected" CRReceivedGroupInvitation u g ct _ _ -> - when (contactNtf u ct) $ + when (contactNtf u ct False) $ sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") - CRUserJoinedGroup u g _ -> when (groupNtf u g) $ do + CRUserJoinedGroup u g _ -> when (groupNtf u g False) $ do whenCurrUser cc u $ setActiveGroup t g sendNtf ("#" <> viewGroupName g, "you are connected to group") CRJoinedGroupMember u g m -> - when (groupNtf u g) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") CRConnectedToGroupMember u g m _ -> - when (groupNtf u g) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") CRReceivedContactRequest u UserContactRequest {localDisplayName = n} -> when (userNtf u) $ sendNtf (viewName n <> ">", "wants to connect to you") _ -> pure () diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index de56baad92..83d0664a04 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -37,7 +37,11 @@ import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime) -import Database.SQLite.Simple.FromField (FromField (..)) +import Data.Typeable (Typeable) +import Database.SQLite.Simple (ResultError (..), SQLData (..)) +import Database.SQLite.Simple.FromField (returnError, FromField(..)) +import Database.SQLite.Simple.Internal (Field (..)) +import Database.SQLite.Simple.Ok import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Types.Preferences @@ -46,7 +50,7 @@ import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util ((<$?>)) import Simplex.Messaging.Version @@ -385,7 +389,7 @@ contactAndGroupIds = \case -- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties) data ChatSettings = ChatSettings - { enableNtfs :: Bool, + { enableNtfs :: MsgFilter, sendRcpts :: Maybe Bool, favorite :: Bool } @@ -396,13 +400,48 @@ instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOpt defaultChatSettings :: ChatSettings defaultChatSettings = ChatSettings - { enableNtfs = True, + { enableNtfs = MFAll, sendRcpts = Nothing, favorite = False } -pattern DisableNtfs :: ChatSettings -pattern DisableNtfs <- ChatSettings {enableNtfs = False} +chatHasNtfs :: ChatSettings -> Bool +chatHasNtfs ChatSettings {enableNtfs} = enableNtfs /= MFNone + +data MsgFilter = MFNone | MFAll | MFMentions + deriving (Eq, Show, Generic) + +instance FromJSON MsgFilter where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MF" + +instance ToJSON MsgFilter where + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MF" + toJSON = J.genericToJSON . enumJSON $ dropPrefix "MF" + +instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP + +instance ToField MsgFilter where toField = toField . msgFilterInt + +msgFilterInt :: MsgFilter -> Int +msgFilterInt = \case + MFNone -> 0 + MFAll -> 1 + MFMentions -> 2 + +msgFilterIntP :: Int64 -> Maybe MsgFilter +msgFilterIntP = \case + 0 -> Just MFNone + 1 -> Just MFAll + 2 -> Just MFMentions + _ -> Just MFAll + +fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a +fromIntField_ fromInt = \case + f@(Field (SQLInteger i) _) -> + case fromInt i of + Just x -> Ok x + _ -> returnError ConversionFailed f ("invalid integer: " <> show i) + f -> returnError ConversionFailed f "expecting SQLInteger column type" featureAllowed :: SChatFeature f -> (PrefEnabled -> Bool) -> Contact -> Bool featureAllowed feature forWhom Contact {mergedPreferences} = @@ -630,6 +669,7 @@ data GroupMember = GroupMember memberRole :: GroupMemberRole, memberCategory :: GroupMemberCategory, memberStatus :: GroupMemberStatus, + memberSettings :: GroupMemberSettings, invitedBy :: InvitedBy, localDisplayName :: ContactName, -- for membership, memberProfile can be either user's profile or incognito profile, based on memberIncognito test. @@ -764,6 +804,16 @@ instance ToJSON GroupMemberRole where toJSON = strToJSON toEncoding = strToJEncoding +data GroupMemberSettings = GroupMemberSettings + { showMessages :: Bool + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupMemberSettings where toEncoding = J.genericToEncoding J.defaultOptions + +defaultMemberSettings :: GroupMemberSettings +defaultMemberSettings = GroupMemberSettings {showMessages = True} + newtype Probe = Probe {unProbe :: ByteString} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f60b7cd82f..f465375f12 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -102,15 +102,15 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView - CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts tz <> viewItemReactions item + CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewChatItem chat item False ts tz <> viewItemReactions item CRChatItems u _ chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] CRChatItemStatusUpdated u ci -> ttyUser u $ viewChatItemStatusUpdated ci ts tz testView showReceipts - CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts tz + CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci - CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView - CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction showReactions chat reaction added ts tz + CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView + CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction u chat reaction $ viewItemReaction showReactions chat reaction added ts tz CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent u mc s f t -> ttyUser u $ viewSentBroadcast mc s f ts tz t CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr @@ -349,24 +349,56 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs - unmuted :: ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] - unmuted chat ChatItem {chatDir} = unmuted' chat chatDir - unmutedReaction :: ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] - unmutedReaction chat CIReaction {chatDir} = unmuted' chat chatDir - unmuted' :: ChatInfo c -> CIDirection c d -> [StyledString] -> [StyledString] - unmuted' chat chatDir s - | muted chat chatDir = [] - | otherwise = s + unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isReference ci + unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] + unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False + unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] + unmuted' u chat chatDir reference s + | chatDirNtf u chat chatDir reference = s + | otherwise = [] + +userNtf :: User -> Bool +userNtf User {showNtfs, activeUser} = showNtfs || activeUser + +chatNtf :: User -> ChatInfo c -> Bool -> Bool +chatNtf user cInfo reference = case cInfo of + DirectChat ct -> contactNtf user ct reference + GroupChat g -> groupNtf user g reference + _ -> False + +chatDirNtf :: User -> ChatInfo c -> CIDirection c d -> Bool -> Bool +chatDirNtf user cInfo chatDir reference = case (cInfo, chatDir) of + (DirectChat ct, CIDirectRcv) -> contactNtf user ct reference + (GroupChat g, CIGroupRcv m) -> groupNtf user g reference && showMessages (memberSettings m) + _ -> True + +contactNtf :: User -> Contact -> Bool -> Bool +contactNtf user Contact {chatSettings} reference = + userNtf user && showMessageNtf chatSettings reference + +groupNtf :: User -> GroupInfo -> Bool -> Bool +groupNtf user GroupInfo {chatSettings} reference = + userNtf user && showMessageNtf chatSettings reference + +showMessageNtf :: ChatSettings -> Bool -> Bool +showMessageNtf ChatSettings {enableNtfs} reference = + enableNtfs == MFAll || (reference && enableNtfs == MFMentions) chatItemDeletedText :: ChatItem c d -> Maybe GroupMember -> Maybe Text -chatItemDeletedText ci membership_ = deletedStateToText <$> chatItemDeletedState ci +chatItemDeletedText ChatItem {meta = CIMeta {itemDeleted}, content} membership_ = + deletedText <$> itemDeleted where - deletedStateToText = \CIDeletedState {markedDeleted, deletedByMember} -> - if markedDeleted - then "marked deleted" <> byMember deletedByMember - else "deleted" <> byMember deletedByMember - byMember m_ = case (m_, membership_) of - (Just GroupMember {groupMemberId = mId, localDisplayName = n}, Just GroupMember {groupMemberId = membershipId}) -> + deletedText = \case + CIModerated _ m -> markedDeleted content <> byMember m + CIDeleted _ -> markedDeleted content + CIBlocked _ -> "blocked" + markedDeleted = \case + CISndModerated -> "deleted" + CIRcvModerated -> "deleted" + _ -> "marked deleted" + byMember GroupMember {groupMemberId = mId, localDisplayName = n} = case membership_ of + Just GroupMember {groupMemberId = membershipId} -> " by " <> if mId == membershipId then "you" else n _ -> "" @@ -385,12 +417,6 @@ viewUsersList = mapMaybe userInfo . sortOn ldn <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] -muted :: ChatInfo c -> CIDirection c d -> Bool -muted chat chatDir = case (chat, chatDir) of - (DirectChat Contact {chatSettings = DisableNtfs}, CIDirectRcv) -> True - (GroupChat GroupInfo {chatSettings = DisableNtfs}, CIGroupRcv _) -> True - _ -> False - viewGroupSubscribed :: GroupInfo -> [StyledString] viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] @@ -692,7 +718,7 @@ viewContactsList = in map (\ct -> ctIncognito ct <> ttyFullContact ct <> muted' ct <> alias ct) . sortOn ldn where muted' Contact {chatSettings, localDisplayName = ldn} - | enableNtfs chatSettings = "" + | chatHasNtfs chatSettings = "" | otherwise = " (muted, you can " <> highlight ("/unmute @" <> ldn) <> ")" alias Contact {profile = LocalProfile {localAlias}} | localAlias == "" = "" @@ -825,22 +851,25 @@ viewGroupMembers :: Group -> [StyledString] viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members where removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft - groupMember m = memIncognito m <> ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m - role :: GroupMember -> StyledString - role m = plain . strEncode $ m.memberRole + groupMember m = memIncognito m <> ttyFullMember m <> ": " <> plain (intercalate ", " $ [role m] <> category m <> status m <> muted m) + role :: GroupMember -> String + role m = B.unpack . strEncode $ m.memberRole category m = case memberCategory m of - GCUserMember -> "you, " - GCInviteeMember -> "invited, " - GCHostMember -> "host, " - _ -> "" + GCUserMember -> ["you"] + GCInviteeMember -> ["invited"] + GCHostMember -> ["host"] + _ -> [] status m = case memberStatus m of - GSMemRemoved -> "removed" - GSMemLeft -> "left" - GSMemInvited -> "not yet joined" - GSMemConnected -> "connected" - GSMemComplete -> "connected" - GSMemCreator -> "created group" - _ -> "" + GSMemRemoved -> ["removed"] + GSMemLeft -> ["left"] + GSMemInvited -> ["not yet joined"] + GSMemConnected -> ["connected"] + GSMemComplete -> ["connected"] + GSMemCreator -> ["created group"] + _ -> [] + muted m + | showMessages (memberSettings m) = [] + | otherwise = ["blocked"] viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] viewContactConnected ct userIncognitoProfile testView = @@ -863,7 +892,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs where ldn_ :: GroupInfo -> Text ldn_ g = T.toLower g.localDisplayName - groupSS (g@GroupInfo {membership, chatSettings}, GroupSummary {currentMembers}) = + groupSS (g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}}, GroupSummary {currentMembers}) = case memberStatus membership of GSMemInvited -> groupInvitation' g s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s @@ -872,9 +901,13 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs GSMemRemoved -> delete "you are removed" GSMemLeft -> delete "you left" GSMemGroupDeleted -> delete "group deleted" - _ - | enableNtfs chatSettings -> " (" <> memberCount <> ")" - | otherwise -> " (" <> memberCount <> ", muted, you can " <> highlight ("/unmute #" <> viewGroupName g) <> ")" + _ -> " (" <> memberCount <> + case enableNtfs of + MFAll -> ")" + MFNone -> ", muted, " <> unmute + MFMentions -> ", mentions only, " <> unmute + where + unmute = "you can " <> highlight ("/unmute #" <> viewGroupName g) <> ")" delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 47333906bb..b4c3c53cd9 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -70,7 +70,7 @@ chatDirectTests = do it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE describe "mute/unmute messages" $ do it "mute/unmute contact" testMuteContact - it "mute/unmute group" testMuteGroup + it "mute/unmute group and member" testMuteGroup describe "multiple users" $ do it "create second user" testCreateSecondUser it "multiple users subscribe and receive messages after restart" testUsersSubscribeAfterRestart @@ -1196,14 +1196,79 @@ testMuteGroup = concurrently_ (bob hi") + bob #> "#team hello" + concurrently_ + (alice <# "#team bob> hello") + (cath <# "#team bob> hello") + cath `send` "> #team (hello) hello too!" + cath <# "#team > bob hello" + cath <## " hello too!" + concurrently_ + (bob > bob hello" + alice <## " hello too!" + ) + bob ##> "/unmute mentions #team" + bob <## "ok" + alice `send` "> #team @bob (hello) hey bob!" + alice <# "#team > bob hello" + alice <## " hey bob!" + concurrently_ + ( do bob <# "#team alice> > bob hello" + bob <## " hey bob!" + ) + ( do cath <# "#team alice> > bob hello" + cath <## " hey bob!" + ) + alice `send` "> #team @cath (hello) hey cath!" + alice <# "#team > cath hello too!" + alice <## " hey cath!" + concurrently_ + (bob > cath hello too!" + cath <## " hey cath!" + ) bob ##> "/gs" - bob <## "#team (3 members, muted, you can /unmute #team)" + bob <## "#team (3 members, mentions only, you can /unmute #team)" bob ##> "/unmute #team" bob <## "ok" alice #> "#team hi again" concurrently_ (bob <# "#team alice> hi again") (cath <# "#team alice> hi again") + bob ##> "/block #team alice" + bob <## "ok" + bob ##> "/ms team" + bob <## "bob (Bob): admin, you, connected" + bob <## "alice (Alice): owner, host, connected, blocked" + bob <## "cath (Catherine): admin, connected" + alice #> "#team test 1" + concurrently_ + (bob test 1") + cath #> "#team test 2" + concurrently_ + (bob <# "#team cath> test 2") + (alice <# "#team cath> test 2") + bob ##> "/tail #team 3" + bob <# "#team alice> hi again" + bob <# "#team alice> test 1 [blocked]" + bob <# "#team cath> test 2" + threadDelay 1000000 + bob ##> "/unblock #team alice" + bob <## "ok" + bob ##> "/ms team" + bob <## "bob (Bob): admin, you, connected" + bob <## "alice (Alice): owner, host, connected" + bob <## "cath (Catherine): admin, connected" + alice #> "#team test 3" + concurrently_ + (bob <# "#team alice> test 3") + (cath <# "#team alice> test 3") + cath #> "#team test 4" + concurrently_ + (bob <# "#team cath> test 4") + (alice <# "#team cath> test 4") bob ##> "/gs" bob <## "#team (3 members)" @@ -1937,7 +2002,7 @@ testUserPrivacy = -- shows hidden user when active alice ##> "/users" alice <## "alice (Alice)" - alice <## "alisa (active, hidden, muted)" + alice <## "alisa (active, hidden, muted, unread: 1)" -- hidden message is saved alice ##> "/tail" alice <##? chatHistory diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6578df84c0..55d02b9488 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1538,7 +1538,6 @@ testGroupDelayedModerationFullDelete tmp = do testGroupAsync :: HasCallStack => FilePath -> IO () testGroupAsync tmp = do - print (0 :: Integer) withNewTestChat tmp "alice" aliceProfile $ \alice -> do withNewTestChat tmp "bob" bobProfile $ \bob -> do connectUsers alice bob From c2a858b06eef39a8d235e072929535f4e8462055 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 19:11:01 +0100 Subject: [PATCH 058/294] core: convert single-field to tagged JSON encoding (#3183) * core: convert single-field to tagged JSON encoding * rename * rename * fixes, test * refactor --- cabal.project | 2 +- package.yaml | 2 ++ scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 5 ++- src/Simplex/Chat/Remote.hs | 39 +++++++++++++++++++++-- stack.yaml | 2 +- tests/JSONTests.hs | 65 ++++++++++++++++++++++++++++++++++++++ tests/RemoteTests.hs | 1 - tests/Test.hs | 2 ++ 9 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 tests/JSONTests.hs diff --git a/cabal.project b/cabal.project index 3da442ac49..1364d77bf2 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 919550948501d315aa8845cbed1781d4298d4ced + tag: 6b0da8ac50b1582c9f5187c316b93fc8f12c9365 source-repository-package type: git diff --git a/package.yaml b/package.yaml index f7fc614789..861d0c494a 100644 --- a/package.yaml +++ b/package.yaml @@ -120,9 +120,11 @@ tests: - apps/simplex-directory-service/src main: Test.hs dependencies: + - QuickCheck == 2.14.* - simplex-chat - async == 2.2.* - deepseq == 1.4.* + - generic-random == 1.5.* - hspec == 2.11.* - network == 3.1.* - silently == 1.2.* diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0a199779d0..e1880738d7 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."919550948501d315aa8845cbed1781d4298d4ced" = "05d0cadhlazqi2lxcb7nvyjrf8q49c6ax7b8rahawbh1zmwg38nm"; + "https://github.com/simplex-chat/simplexmq.git"."6b0da8ac50b1582c9f5187c316b93fc8f12c9365" = "18n0b1l1adraw5rq118a6iz9pqg43yf41vrzm193q1si06iwk24b"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 81a71a9109..4fc023bc3b 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -488,6 +488,7 @@ test-suite simplex-chat-test ChatTests.Groups ChatTests.Profiles ChatTests.Utils + JSONTests MarkdownTests MobileTests ProtocolTests @@ -509,7 +510,8 @@ test-suite simplex-chat-test apps/simplex-directory-service/src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* + QuickCheck ==2.14.* + , aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -528,6 +530,7 @@ test-suite simplex-chat-test , email-validate ==2.3.* , exceptions ==0.10.* , filepath ==1.4.* + , generic-random ==1.5.* , hspec ==2.11.* , http-types ==0.12.* , http2 ==4.1.* diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 0f03c1fdb5..b81ba33cda 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -4,6 +4,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -16,7 +17,10 @@ import Control.Monad.IO.Class import Control.Monad.Reader (asks) import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) +import Data.Aeson ((.=)) import qualified Data.Aeson as J +import qualified Data.Aeson.Key as JK +import qualified Data.Aeson.KeyMap as JM import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Binary.Builder as Binary import Data.ByteString (ByteString, hPut) @@ -47,6 +51,7 @@ import Simplex.FileTransfer.Util (uniqueCombine) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import Simplex.Messaging.Parsers (pattern SingleFieldJSONTag, pattern TaggedObjectJSONTag, pattern TaggedObjectJSONData) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) @@ -233,9 +238,37 @@ handleRcvFileComplete http storePath remoteUser cif@CIFile {fileId, fileName, fi -- | Convert swift single-field sum encoding into tagged/discriminator-field owsf2tagged :: J.Value -> J.Value -owsf2tagged = \case - J.Object todo'convert -> J.Object todo'convert - skip -> skip +owsf2tagged = fst . convert + where + convert val = case val of + J.Object o + | JM.size o == 2 -> + case JM.toList o of + [OwsfTag, o'] -> tagged o' + [o', OwsfTag] -> tagged o' + _ -> props + | otherwise -> props + where + props = (J.Object $ fmap owsf2tagged o, False) + J.Array a -> (J.Array $ fmap owsf2tagged a, False) + _ -> (val, False) + -- `tagged` converts the pair of single-field object encoding to tagged encoding. + -- It sets innerTag returned by `convert` to True to prevent the tag being overwritten. + tagged (k, v) = (J.Object pairs, True) + where + (v', innerTag) = convert v + pairs = case v' of + -- `innerTag` indicates that internal object already has tag, + -- so the current tag cannot be inserted into it. + J.Object o + | innerTag -> pair + | otherwise -> JM.insert TaggedObjectJSONTag tag o + _ -> pair + tag = J.String $ JK.toText k + pair = JM.fromList [TaggedObjectJSONTag .= tag, TaggedObjectJSONData .= v'] + +pattern OwsfTag :: (JK.Key, J.Value) +pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) storeRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> FilePath -> m (Maybe FilePath) storeRemoteFile http localFile = do diff --git a/stack.yaml b/stack.yaml index 5d9fc214fb..e467b040e9 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 919550948501d315aa8845cbed1781d4298d4ced + commit: 6b0da8ac50b1582c9f5187c316b93fc8f12c9365 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs new file mode 100644 index 0000000000..11567d94af --- /dev/null +++ b/tests/JSONTests.hs @@ -0,0 +1,65 @@ +{-# LANGUAGE DeriveGeneric #-} + +module JSONTests where + +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson as J +import qualified Data.Aeson.Types as JT +import qualified Data.ByteString.Lazy.Char8 as LB +import GHC.Generics (Generic) +import Generic.Random (genericArbitraryU) +import MobileTests +import Simplex.Chat.Remote (owsf2tagged) +import Simplex.Messaging.Parsers +import Test.Hspec +import Test.Hspec.QuickCheck (modifyMaxSuccess) +import Test.QuickCheck (Arbitrary (..), property) + +jsonTests :: Spec +jsonTests = describe "owsf2tagged" $ do + it "should convert chat types" owsf2TaggedJSONTest + describe "SomeType" owsf2TaggedSomeTypeTests + +owsf2TaggedJSONTest :: IO () +owsf2TaggedJSONTest = do + noActiveUserSwift `to` noActiveUserTagged + activeUserExistsSwift `to` activeUserExistsTagged + activeUserSwift `to` activeUserTagged + chatStartedSwift `to` chatStartedTagged + contactSubSummarySwift `to` contactSubSummaryTagged + memberSubSummarySwift `to` memberSubSummaryTagged + userContactSubSummarySwift `to` userContactSubSummaryTagged + pendingSubSummarySwift `to` pendingSubSummaryTagged + parsedMarkdownSwift `to` parsedMarkdownTagged + where + to :: LB.ByteString -> LB.ByteString -> IO () + owsf `to` tagged = + case J.eitherDecode owsf of + Right json -> Right (owsf2tagged json) `shouldBe` J.eitherDecode tagged + Left e -> expectationFailure e + +data SomeType + = Nullary + | Unary (Maybe SomeType) + | Product String (Maybe SomeType) + | Record + { testOne :: Int, + testTwo :: Maybe Bool, + testThree :: Maybe SomeType + } + | List [Int] + deriving (Eq, Show, Generic) + +instance Arbitrary SomeType where arbitrary = genericArbitraryU + +instance ToJSON SomeType where + toJSON = J.genericToJSON $ singleFieldJSON_ (Just SingleFieldJSONTag) id + toEncoding = J.genericToEncoding $ singleFieldJSON_ (Just SingleFieldJSONTag) id + +instance FromJSON SomeType where + parseJSON = J.genericParseJSON $ taggedObjectJSON id + +owsf2TaggedSomeTypeTests :: Spec +owsf2TaggedSomeTypeTests = + modifyMaxSuccess (const 10000) $ it "should convert to tagged" $ property $ \x -> + (JT.parseMaybe J.parseJSON . owsf2tagged . J.toJSON) x == Just (x :: SomeType) diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 34e2b04a6f..84f361a4a4 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -2,7 +2,6 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} module RemoteTests where diff --git a/tests/Test.hs b/tests/Test.hs index 1e2cad0376..071ff3791e 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -5,6 +5,7 @@ import ChatTests import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System +import JSONTests import MarkdownTests import MobileTests import ProtocolTests @@ -22,6 +23,7 @@ main = do withGlobalLogging logCfg . hspec $ do describe "Schema dump" schemaDumpTest describe "SimpleX chat markdown" markdownTests + describe "JSON Tests" jsonTests describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests describe "WebRTC encryption" webRTCTests From 8ff6b392c2444b023cb8668069cc27709702ad6d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 21:15:31 +0100 Subject: [PATCH 059/294] core: rename "reference" to "mention" --- src/Simplex/Chat/Messages.hs | 4 ++-- src/Simplex/Chat/Terminal/Output.hs | 6 +++--- src/Simplex/Chat/View.hs | 30 ++++++++++++++--------------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 22506218aa..21da83ad05 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -150,8 +150,8 @@ instance MsgDirectionI d => ToJSON (ChatItem c d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} -isReference :: ChatItem c d -> Bool -isReference ChatItem {chatDir, quotedItem} = case chatDir of +isMention :: ChatItem c d -> Bool +isMention ChatItem {chatDir, quotedItem} = case chatDir of CIDirectRcv -> userItem quotedItem CIGroupRcv _ -> userItem quotedItem _ -> False diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index a45390e8c3..a623536cd9 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -151,7 +151,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d responseNotification ct cc r where markChatItemRead u (AChatItem _ _ chat ci@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = - case (chatDirNtf u chat chatDir (isReference ci), itemStatus) of + case (chatDirNtf u chat chatDir (isMention ci), itemStatus) of (True, CISRcvNew) -> do let itemId = chatItemId' ci chatRef = chatInfoToRef chat @@ -162,7 +162,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () responseNotification t@ChatTerminal {sendNotification} cc = \case CRNewChatItem u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) -> - when (chatDirNtf u cInfo chatDir $ isReference ci) $ do + when (chatDirNtf u cInfo chatDir $ isMention ci) $ do whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) @@ -171,7 +171,7 @@ responseNotification t@ChatTerminal {sendNotification} cc = \case where text = msgText mc formattedText CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> - whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isReference ci) $ setActiveChat t cInfo + whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isMention ci) $ setActiveChat t cInfo CRContactConnected u ct _ -> when (contactNtf u ct False) $ do whenCurrUser cc u $ setActiveContact t ct sendNtf (viewContactName ct <> "> ", "connected") diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f465375f12..86a10988d6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -350,40 +350,40 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] - unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isReference ci + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isMention ci unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] - unmuted' u chat chatDir reference s - | chatDirNtf u chat chatDir reference = s + unmuted' u chat chatDir mention s + | chatDirNtf u chat chatDir mention = s | otherwise = [] userNtf :: User -> Bool userNtf User {showNtfs, activeUser} = showNtfs || activeUser chatNtf :: User -> ChatInfo c -> Bool -> Bool -chatNtf user cInfo reference = case cInfo of - DirectChat ct -> contactNtf user ct reference - GroupChat g -> groupNtf user g reference +chatNtf user cInfo mention = case cInfo of + DirectChat ct -> contactNtf user ct mention + GroupChat g -> groupNtf user g mention _ -> False chatDirNtf :: User -> ChatInfo c -> CIDirection c d -> Bool -> Bool -chatDirNtf user cInfo chatDir reference = case (cInfo, chatDir) of - (DirectChat ct, CIDirectRcv) -> contactNtf user ct reference - (GroupChat g, CIGroupRcv m) -> groupNtf user g reference && showMessages (memberSettings m) +chatDirNtf user cInfo chatDir mention = case (cInfo, chatDir) of + (DirectChat ct, CIDirectRcv) -> contactNtf user ct mention + (GroupChat g, CIGroupRcv m) -> groupNtf user g mention && showMessages (memberSettings m) _ -> True contactNtf :: User -> Contact -> Bool -> Bool -contactNtf user Contact {chatSettings} reference = - userNtf user && showMessageNtf chatSettings reference +contactNtf user Contact {chatSettings} mention = + userNtf user && showMessageNtf chatSettings mention groupNtf :: User -> GroupInfo -> Bool -> Bool -groupNtf user GroupInfo {chatSettings} reference = - userNtf user && showMessageNtf chatSettings reference +groupNtf user GroupInfo {chatSettings} mention = + userNtf user && showMessageNtf chatSettings mention showMessageNtf :: ChatSettings -> Bool -> Bool -showMessageNtf ChatSettings {enableNtfs} reference = - enableNtfs == MFAll || (reference && enableNtfs == MFMentions) +showMessageNtf ChatSettings {enableNtfs} mention = + enableNtfs == MFAll || (mention && enableNtfs == MFMentions) chatItemDeletedText :: ChatItem c d -> Maybe GroupMember -> Maybe Text chatItemDeletedText ChatItem {meta = CIMeta {itemDeleted}, content} membership_ = From 7692195bfaf33b5c8cf1a9f014c07f34f5a937e4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 21:57:53 +0100 Subject: [PATCH 060/294] core: fix for ghc 8.10.7 --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Store/Shared.hs | 1 - src/Simplex/Chat/Util.hs | 1 - src/Simplex/Chat/View.hs | 4 ++-- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c58fef8602..f0c079cf5f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -15,7 +15,7 @@ module Simplex.Chat where import Control.Applicative (optional, (<|>)) -import Control.Concurrent.STM (retry, stateTVar) +import Control.Concurrent.STM (retry) import Control.Logger.Simple import Control.Monad.Except import Control.Monad.IO.Unlift diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 2209ca8cfa..df0d8d9f77 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -10,7 +10,6 @@ module Simplex.Chat.Store.Shared where -import Control.Concurrent.STM (stateTVar) import Control.Exception (Exception) import qualified Control.Exception as E import Control.Monad.Except diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 46b5be28b3..3ce663e69f 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -2,7 +2,6 @@ module Simplex.Chat.Util (week, encryptFile, chunkSize) where import Control.Monad import Control.Monad.Except -import Control.Monad.IO.Class import qualified Data.ByteString.Lazy as LB import Data.Time (NominalDiffTime) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 4b0398dfd4..ff72f9b4c2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -885,10 +885,10 @@ viewContactConnected ct userIncognitoProfile testView = viewGroupsList :: [(GroupInfo, GroupSummary)] -> [StyledString] viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] -viewGroupsList gs = map groupSS $ sortOn ldn_ gs +viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs where ldn_ :: GroupInfo -> Text - ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName) . fst + ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName) groupSS (g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}}, GroupSummary {currentMembers}) = case memberStatus membership of GSMemInvited -> groupInvitation' g From 4df8ea2e78fa89deec5857d407a15b5574450803 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 23:07:05 +0100 Subject: [PATCH 061/294] ui: update types for notification and member settings (#3201) --- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++---------- apps/ios/SimpleXChat/APITypes.swift | 12 ++++-- apps/ios/SimpleXChat/ChatTypes.swift | 12 ++++-- .../chat/simplex/common/model/ChatModel.kt | 17 +++++--- .../chat/simplex/common/model/SimpleXAPI.kt | 11 ++++- .../views/chatlist/ChatListNavLinkView.kt | 6 +-- 7 files changed, 62 insertions(+), 38 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 389080efc5..81473709cb 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -965,7 +965,7 @@ struct ChatView: View { func toggleNotifications(_ chat: Chat, enableNtfs: Bool) { var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults - chatSettings.enableNtfs = enableNtfs + chatSettings.enableNtfs = enableNtfs ? .all : .none updateChatSettings(chat, chatSettings: chatSettings) } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 18021e3079..35d0b1de8a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -85,6 +85,11 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */; }; + 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0112AD746C8001FD661 /* libgmpxx.a */; }; + 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0122AD746C8001FD661 /* libffi.a */; }; + 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; }; + 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; }; + 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0152AD746C8001FD661 /* libgmp.a */; }; 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; }; 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; }; 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; @@ -114,11 +119,6 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; - 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739922AD44E2E009470A9 /* libgmp.a */; }; - 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */; }; - 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739942AD44E2E009470A9 /* libffi.a */; }; - 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */; }; - 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739962AD44E2E009470A9 /* libgmpxx.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; @@ -358,6 +358,11 @@ 5CA85D0A297218AA0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 5CA85D0C297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = "it.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CA85D0D297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; + 5CA8D0112AD746C8001FD661 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CA8D0122AD746C8001FD661 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; }; + 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; }; + 5CA8D0152AD746C8001FD661 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5CAB912529E93F9400F34A95 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 5CAC41182A192D8400C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 5CAC411A2A192DE800C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = "ja.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; @@ -395,11 +400,6 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; - 5CC739922AD44E2E009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a"; sourceTree = ""; }; - 5CC739942AD44E2E009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a"; sourceTree = ""; }; - 5CC739962AD44E2E009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; @@ -507,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */, + 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */, + 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */, + 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, + 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */, - 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */, - 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */, + 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +574,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CC739942AD44E2E009470A9 /* libffi.a */, - 5CC739922AD44E2E009470A9 /* libgmp.a */, - 5CC739962AD44E2E009470A9 /* libgmpxx.a */, - 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */, - 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */, + 5CA8D0122AD746C8001FD661 /* libffi.a */, + 5CA8D0152AD746C8001FD661 /* libgmp.a */, + 5CA8D0112AD746C8001FD661 /* libgmpxx.a */, + 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, + 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 951f726be9..d65b9b3283 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1182,17 +1182,23 @@ public struct KeepAliveOpts: Codable, Equatable { } public struct ChatSettings: Codable { - public var enableNtfs: Bool + public var enableNtfs: MsgFilter public var sendRcpts: Bool? public var favorite: Bool - public init(enableNtfs: Bool, sendRcpts: Bool?, favorite: Bool) { + public init(enableNtfs: MsgFilter, sendRcpts: Bool?, favorite: Bool) { self.enableNtfs = enableNtfs self.sendRcpts = sendRcpts self.favorite = favorite } - public static let defaults: ChatSettings = ChatSettings(enableNtfs: true, sendRcpts: nil, favorite: false) + public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false) +} + +public enum MsgFilter: String, Codable { + case none + case all + case mentions } public struct UserMsgReceiptSettings: Codable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f9996d8400..37e4f0316a 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1292,7 +1292,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } public var ntfsEnabled: Bool { - self.chatSettings?.enableNtfs ?? false + self.chatSettings?.enableNtfs == .all } public var chatSettings: ChatSettings? { @@ -1758,6 +1758,7 @@ public struct GroupMember: Identifiable, Decodable { public var memberRole: GroupMemberRole public var memberCategory: GroupMemberCategory public var memberStatus: GroupMemberStatus + public var memberSettings: GroupMemberSettings public var invitedBy: InvitedBy public var localDisplayName: ContactName public var memberProfile: LocalProfile @@ -1851,6 +1852,7 @@ public struct GroupMember: Identifiable, Decodable { memberRole: .admin, memberCategory: .inviteeMember, memberStatus: .memComplete, + memberSettings: GroupMemberSettings(showMessages: true), invitedBy: .user, localDisplayName: "alice", memberProfile: LocalProfile.sampleData, @@ -1860,6 +1862,10 @@ public struct GroupMember: Identifiable, Decodable { ) } +public struct GroupMemberSettings: Decodable { + var showMessages: Bool +} + public struct GroupMemberRef: Decodable { var groupMemberId: Int64 var profile: Profile @@ -1983,8 +1989,8 @@ public enum ConnectionEntity: Decodable { public var ntfsEnabled: Bool { switch self { - case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs ?? false - case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs + case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs == .all + case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs == .all case .sndFileConnection: return false case .rcvFileConnection: return false case let .userContactConnection(userContact): return userContact.groupId == nil diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 8687ac390a..88ad78612c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -12,7 +12,6 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -726,7 +725,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contactConnection.apiId override val ready get() = contactConnection.ready override val sendMsgEnabled get() = contactConnection.sendMsgEnabled - override val ntfsEnabled get() = contactConnection.incognito + override val ntfsEnabled get() = false override val incognito get() = contactConnection.incognito override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL @@ -822,7 +821,7 @@ data class Contact( (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent - override val ntfsEnabled get() = chatSettings.enableNtfs + override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser @@ -869,7 +868,7 @@ data class Contact( activeConn = Connection.sampleData, contactUsed = true, contactStatus = ContactStatus.Active, - chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), + chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), userPreferences = ChatPreferences.sampleData, mergedPreferences = ContactUserPreferences.sampleData, createdAt = Clock.System.now(), @@ -1009,7 +1008,7 @@ data class GroupInfo ( override val apiId get() = groupId override val ready get() = membership.memberActive override val sendMsgEnabled get() = membership.memberActive - override val ntfsEnabled get() = chatSettings.enableNtfs + override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = membership.memberIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on @@ -1041,7 +1040,7 @@ data class GroupInfo ( fullGroupPreferences = FullGroupPreferences.sampleData, membership = GroupMember.sampleData, hostConnCustomUserProfileId = null, - chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), + chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -1073,6 +1072,7 @@ data class GroupMember ( var memberRole: GroupMemberRole, var memberCategory: GroupMemberCategory, var memberStatus: GroupMemberStatus, + var memberSettings: GroupMemberSettings, var invitedBy: InvitedBy, val localDisplayName: String, val memberProfile: LocalProfile, @@ -1140,6 +1140,7 @@ data class GroupMember ( memberRole = GroupMemberRole.Member, memberCategory = GroupMemberCategory.InviteeMember, memberStatus = GroupMemberStatus.MemComplete, + memberSettings = GroupMemberSettings(showMessages = true), invitedBy = InvitedBy.IBUser(), localDisplayName = "alice", memberProfile = LocalProfile.sampleData, @@ -1150,6 +1151,9 @@ data class GroupMember ( } } +@Serializable +data class GroupMemberSettings(val showMessages: Boolean) {} + @Serializable class GroupMemberRef( val groupMemberId: Long, @@ -1844,6 +1848,7 @@ enum class SndCIStatusProgress { @Serializable sealed class CIDeleted { @Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted() + @Serializable @SerialName("blocked") class Blocked(val deletedTs: Instant?): CIDeleted() @Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 43043d65fe..3644268ba3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2472,15 +2472,22 @@ data class KeepAliveOpts( @Serializable data class ChatSettings( - val enableNtfs: Boolean, + val enableNtfs: MsgFilter, val sendRcpts: Boolean?, val favorite: Boolean ) { companion object { - val defaults: ChatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false) + val defaults: ChatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false) } } +@Serializable +enum class MsgFilter { + @SerialName("all") All, + @SerialName("none") None, + @SerialName("mentions") Mentions, +} + @Serializable data class UserMsgReceiptSettings(val enable: Boolean, val clearOverrides: Boolean) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 41c94f21d9..566c981811 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -595,8 +595,8 @@ fun groupInvitationAcceptedAlert() { ) } -fun toggleNotifications(chat: Chat, enableNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { - val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = enableNtfs) +fun toggleNotifications(chat: Chat, enableAllNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { + val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = if (enableAllNtfs) MsgFilter.All else MsgFilter.None) updateChatSettings(chat, chatSettings, chatModel, currentState) } @@ -627,7 +627,7 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo } if (res && newChatInfo != null) { chatModel.updateChatInfo(newChatInfo) - if (!chatSettings.enableNtfs) { + if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chat.id) } val current = currentState?.value From 7b488c7f1ba661d6198b68c88926cd586e8b8d42 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 12 Oct 2023 11:52:14 +0400 Subject: [PATCH 062/294] tests: improve tests (#3203) --- tests/ChatTests/Groups.hs | 2 +- tests/ChatTests/Profiles.hs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 55d02b9488..77b70c3e78 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2461,7 +2461,7 @@ testPlanGroupLinkLeaveRejoin = concurrentlyN_ [ alice <### [ "bob_1 (Bob): contact is connected", - "bob_1 invited to group #team via your group link", + EndsWith "invited to group #team via your group link", EndsWith "joined the group", "contact bob_1 is merged into bob", "use @bob to send messages" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 0d7683c4d7..80e1709222 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -650,8 +650,10 @@ testPlanAddressConnecting tmp = do alice ##> "/ad" getContactLink alice True withNewTestChat tmp "bob" bobProfile $ \bob -> do + threadDelay 100000 bob ##> ("/c " <> cLink) bob <## "connection request sent!" + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" alice <## "bob (Bob) wants to connect to you!" From 247f2c9e618eba9fa5eef8277efe8df666467e49 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:26:44 +0400 Subject: [PATCH 063/294] tests: modify testMemberContactInvitedConnectionReplaced to not rely on chat item order, print output (#3198) --- tests/ChatClient.hs | 2 ++ tests/ChatTests/Groups.hs | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 7da5263253..7b3fb44998 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -210,6 +210,8 @@ withTestChatOpts tmp = withTestChatCfgOpts tmp testCfg withTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a withTestChatCfgOpts tmp cfg opts dbPrefix = bracket (startTestChat tmp cfg opts dbPrefix) (\cc -> cc > stopTestChat cc) +-- enable output for specific chat controller, use like this: +-- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ... withTestOutput :: HasCallStack => TestCC -> (HasCallStack => TestCC -> IO a) -> IO a withTestOutput cc runTest = runTest cc {printOutput = True} diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bf740a960f..82a5c1bd67 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2886,9 +2886,9 @@ testMemberContactProhibitedRepeatInv = testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \b -> withTestOutput b $ \bob -> do + withNewTestChat tmp "cath" cathProfile $ \c -> withTestOutput c $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -2910,7 +2910,9 @@ testMemberContactInvitedConnectionReplaced tmp = do (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "hi"), (0, "security code changed")] <> chatFeatures) + bob ##> "/_get chat @2 count=100" + items <- chat <$> getTermLine bob + items `shouldContain` [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 From b956988a83a89e357b63cfb6391132b7b84b988d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:20:31 +0400 Subject: [PATCH 064/294] tests: simplify testMemberContactInvitedConnectionReplaced (for stable) --- tests/ChatTests/Groups.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 77b70c3e78..4c31805265 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3278,7 +3278,7 @@ testMemberContactInvitedConnectionReplaced tmp = do bob ##> "/_get chat @2 count=100" items <- chat <$> getTermLine bob - items `shouldContain` [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] + items `shouldContain` [(0, "security code changed")] withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 From 5d078bec536eb0149c1205cb80d6bdbb21e25497 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:20:31 +0400 Subject: [PATCH 065/294] tests: simplify testMemberContactInvitedConnectionReplaced (for stable) --- tests/ChatTests/Groups.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 82a5c1bd67..1d2ff7ad97 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2912,7 +2912,7 @@ testMemberContactInvitedConnectionReplaced tmp = do bob ##> "/_get chat @2 count=100" items <- chat <$> getTermLine bob - items `shouldContain` [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] + items `shouldContain` [(0, "security code changed")] withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 From adc1f8c983878bbabb0bc912404884464f2ddeab Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:58:59 +0300 Subject: [PATCH 066/294] android, desktop: remote kotlin types (#3200) * Add remote types to Kotlin * update response info for chat console --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/SimpleXAPI.kt | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 3644268ba3..50c52b6317 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1915,6 +1915,18 @@ sealed class CC { class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() + class CreateRemoteHost(): CC() + class ListRemoteHosts(): CC() + class StartRemoteHost(val remoteHostId: Long): CC() + class StopRemoteHost(val remoteHostId: Long): CC() + class DeleteRemoteHost(val remoteHostId: Long): CC() + class RegisterRemoteCtrl(val remoteCtrlOOB: RemoteCtrlOOB): CC() + class StartRemoteCtrl(): CC() + class ListRemoteCtrls(): CC() + class AcceptRemoteCtrl(val remoteCtrlId: Long): CC() + class RejectRemoteCtrl(val remoteCtrlId: Long): CC() + class StopRemoteCtrl(): CC() + class DeleteRemoteCtrl(val remoteCtrlId: Long): CC() class ShowVersion(): CC() val cmdString: String get() = when (this) { @@ -2022,6 +2034,18 @@ sealed class CC { is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" + is CreateRemoteHost -> "/create remote host" + is ListRemoteHosts -> "/list remote hosts" + is StartRemoteHost -> "/start remote host $remoteHostId" + is StopRemoteHost -> "/stop remote host $remoteHostId" + is DeleteRemoteHost -> "/delete remote host $remoteHostId" + is StartRemoteCtrl -> "/start remote ctrl" + is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.caFingerprint}" + is AcceptRemoteCtrl -> "/accept remote ctrl $remoteCtrlId" + is RejectRemoteCtrl -> "/reject remote ctrl $remoteCtrlId" + is ListRemoteCtrls -> "/list remote ctrls" + is StopRemoteCtrl -> "/stop remote ctrl" + is DeleteRemoteCtrl -> "/delete remote ctrl $remoteCtrlId" is ShowVersion -> "/version" } @@ -2118,6 +2142,18 @@ sealed class CC { is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" is CancelFile -> "cancelFile" + is CreateRemoteHost -> "createRemoteHost" + is ListRemoteHosts -> "listRemoteHosts" + is StartRemoteHost -> "startRemoteHost" + is StopRemoteHost -> "stopRemoteHost" + is DeleteRemoteHost -> "deleteRemoteHost" + is RegisterRemoteCtrl -> "registerRemoteCtrl" + is StartRemoteCtrl -> "startRemoteCtrl" + is ListRemoteCtrls -> "listRemoteCtrls" + is AcceptRemoteCtrl -> "acceptRemoteCtrl" + is RejectRemoteCtrl -> "rejectRemoteCtrl" + is StopRemoteCtrl -> "stopRemoteCtrl" + is DeleteRemoteCtrl -> "deleteRemoteCtrl" is ShowVersion -> "showVersion" } @@ -3180,6 +3216,34 @@ enum class GroupFeatureEnabled { } +@Serializable +data class RemoteCtrl ( + val remoteCtrlId: Long, + val displayName: String, + val fingerprint: String, + val accepted: Boolean? +) + +@Serializable +data class RemoteCtrlOOB ( + val caFingerprint: String +) + +@Serializable +data class RemoteCtrlInfo ( + val remoteCtrlId: Long, + val displayName: String, + val sessionActive: Boolean +) + +@Serializable +data class RemoteHostInfo ( + val remoteHostId: Long, + val storePath: String, + val displayName: String, + val sessionActive: Boolean +) + val json = Json { prettyPrint = true ignoreUnknownKeys = true @@ -3401,6 +3465,26 @@ sealed class CR { @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List): CR() + // remote events (desktop) + @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHostId: Long, val oobData: RemoteCtrlOOB): CR() + @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() + @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostDeleted") class RemoteHostDeleted(val remoteHostId: Long): CR() + // remote events (mobile) + @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() + @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlStarted") class RemoteCtrlStarted(): CR() + @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() + @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrl): CR() + @Serializable @SerialName("remoteCtrlAccepted") class RemoteCtrlAccepted(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlRejected") class RemoteCtrlRejected(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() + @Serializable @SerialName("remoteCtrlDeleted") class RemoteCtrlDeleted(val remoteCtrlId: Long): CR() + // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -3529,6 +3613,23 @@ sealed class CR { is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" is ArchiveImported -> "archiveImported" + is RemoteHostCreated -> "remoteHostCreated" + is RemoteHostList -> "remoteHostList" + is RemoteHostStarted -> "remoteHostStarted" + is RemoteHostConnected -> "remoteHostConnected" + is RemoteHostStopped -> "remoteHostStopped" + is RemoteHostDeleted -> "remoteHostDeleted" + is RemoteCtrlList -> "remoteCtrlList" + is RemoteCtrlRegistered -> "remoteCtrlRegistered" + is RemoteCtrlStarted -> "remoteCtrlStarted" + is RemoteCtrlAnnounce -> "remoteCtrlAnnounce" + is RemoteCtrlFound -> "remoteCtrlFound" + is RemoteCtrlAccepted -> "remoteCtrlAccepted" + is RemoteCtrlRejected -> "remoteCtrlRejected" + is RemoteCtrlConnecting -> "remoteCtrlConnecting" + is RemoteCtrlConnected -> "remoteCtrlConnected" + is RemoteCtrlStopped -> "remoteCtrlStopped" + is RemoteCtrlDeleted -> "remoteCtrlDeleted" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -3660,6 +3761,23 @@ sealed class CR { is ChatCmdError -> withUser(user_, chatError.string) is ChatRespError -> withUser(user_, chatError.string) is ArchiveImported -> "${archiveErrors.map { it.string } }" + is RemoteHostCreated -> "remote host ID: $remoteHostId\noobData ${json.encodeToString(oobData)}" + is RemoteHostList -> "remote hosts: ${json.encodeToString(remoteHosts)}" + is RemoteHostStarted -> "remote host $remoteHostId" + is RemoteHostConnected -> "remote host ID: $remoteHostId" + is RemoteHostStopped -> "remote host ID: $remoteHostId" + is RemoteHostDeleted -> "remote host ID: $remoteHostId" + is RemoteCtrlList -> json.encodeToString(remoteCtrls) + is RemoteCtrlRegistered -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlStarted -> "" + is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" + is RemoteCtrlFound -> "remote ctrl: ${json.encodeToString(remoteCtrl)}" + is RemoteCtrlAccepted -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlRejected -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlConnecting -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" + is RemoteCtrlConnected -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" + is RemoteCtrlStopped -> "" + is RemoteCtrlDeleted -> "remote ctrl ID: $remoteCtrlId" is Response -> json is Invalid -> str } @@ -3805,12 +3923,16 @@ sealed class ChatError { is ChatErrorAgent -> "agent ${agentError.string}" is ChatErrorStore -> "store ${storeError.string}" is ChatErrorDatabase -> "database ${databaseError.string}" + is ChatErrorRemoteCtrl -> "remoteCtrl ${remoteCtrlError.string}" + is ChatErrorRemoteHost -> "remoteHost ${remoteHostError.string}" is ChatErrorInvalidJSON -> "invalid json ${json}" } @Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError() @Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError() @Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError() @Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError() + @Serializable @SerialName("errorRemoteCtrl") class ChatErrorRemoteCtrl(val remoteCtrlError: RemoteCtrlError): ChatError() + @Serializable @SerialName("errorRemoteHost") class ChatErrorRemoteHost(val remoteHostError: RemoteHostError): ChatError() @Serializable @SerialName("invalidJSON") class ChatErrorInvalidJSON(val json: String): ChatError() } @@ -4310,6 +4432,47 @@ sealed class ArchiveError { @Serializable @SerialName("importFile") class ArchiveErrorImportFile(val file: String, val chatError: ChatError): ArchiveError() } +@Serializable +sealed class RemoteHostError { + val string: String get() = when (this) { + is Missing -> "missing" + is Busy -> "busy" + is Rejected -> "rejected" + is Timeout -> "timeout" + is Disconnected -> "disconnected" + is ConnectionLost -> "connectionLost" + } + @Serializable @SerialName("missing") object Missing: RemoteHostError() + @Serializable @SerialName("busy") object Busy: RemoteHostError() + @Serializable @SerialName("rejected") object Rejected: RemoteHostError() + @Serializable @SerialName("timeout") object Timeout: RemoteHostError() + @Serializable @SerialName("disconnected") class Disconnected(val reason: String): RemoteHostError() + @Serializable @SerialName("connectionLost") class ConnectionLost(val reason: String): RemoteHostError() +} + +@Serializable +sealed class RemoteCtrlError { + val string: String get() = when (this) { + is Missing -> "missing" + is Inactive -> "inactive" + is Busy -> "busy" + is Timeout -> "timeout" + is Disconnected -> "disconnected" + is ConnectionLost -> "connectionLost" + is CertificateExpired -> "certificateExpired" + is CertificateUntrusted -> "certificateUntrusted" + is BadFingerprint -> "badFingerprint" + } + @Serializable @SerialName("missing") class Missing(val remoteCtrlId: Long): RemoteCtrlError() + @Serializable @SerialName("inactive") object Inactive: RemoteCtrlError() + @Serializable @SerialName("busy") object Busy: RemoteCtrlError() + @Serializable @SerialName("timeout") object Timeout: RemoteCtrlError() + @Serializable @SerialName("disconnected") class Disconnected(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() + @Serializable @SerialName("connectionLost") class ConnectionLost(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() + @Serializable @SerialName("certificateExpired") class CertificateExpired(val remoteCtrlId: Long): RemoteCtrlError() + @Serializable @SerialName("certificateUntrusted") class CertificateUntrusted(val remoteCtrlId: Long): RemoteCtrlError() + @Serializable @SerialName("badFingerprint") object BadFingerprint: RemoteCtrlError() +} enum class NotificationsMode() { OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */; From 8ffe1c23c176b7d857b3ef534be26e2b5a67b41d Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 12 Oct 2023 20:19:57 +0800 Subject: [PATCH 067/294] android, desktop: better handling of parallel updates of chats (#3204) * android, desktop: better handling of parallel updates of chats * one more case --- .../main/java/chat/simplex/app/SimplexApp.kt | 32 +++++++++-------- .../chat/simplex/common/model/ChatModel.kt | 34 +++++++++++-------- .../chat/simplex/common/model/SimpleXAPI.kt | 14 +++++--- .../common/views/database/DatabaseView.kt | 8 +++-- 4 files changed, 53 insertions(+), 35 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index f70032788b..d2c4465172 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -9,12 +9,14 @@ import chat.simplex.common.helpers.APPLICATION_ID import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.platform.* import chat.simplex.common.views.call.RcvCallInvitation import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* +import kotlinx.coroutines.sync.withLock import java.io.* import java.util.* import java.util.concurrent.TimeUnit @@ -52,21 +54,23 @@ class SimplexApp: Application(), LifecycleEventObserver { Lifecycle.Event.ON_START -> { isAppOnForeground = true if (chatModel.chatRunning.value == true) { - kotlin.runCatching { - val currentUserId = chatModel.currentUser.value?.userId - val chats = ArrayList(chatController.apiGetChats()) - /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ - if (chatModel.currentUser.value?.userId == currentUserId) { - val currentChatId = chatModel.chatId.value - val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null - if (oldStats != null) { - val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId } - /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ - if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) + updatingChatsMutex.withLock { + kotlin.runCatching { + val currentUserId = chatModel.currentUser.value?.userId + val chats = ArrayList(chatController.apiGetChats()) + /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ + if (chatModel.currentUser.value?.userId == currentUserId) { + val currentChatId = chatModel.chatId.value + val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null + if (oldStats != null) { + val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId } + /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ + if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) + } + chatModel.updateChats(chats) } - chatModel.updateChats(chats) - } - }.onFailure { Log.e(TAG, it.stackTraceToString()) } + }.onFailure { Log.e(TAG, it.stackTraceToString()) } + } } } Lifecycle.Event.ON_RESUME -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 88ad78612c..ac5f9fb8a5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration -import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -16,6 +15,8 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.* import kotlinx.datetime.TimeZone import kotlinx.serialization.* @@ -102,6 +103,8 @@ object ChatModel { val filesToDelete = mutableSetOf() val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } + var updatingChatsMutex: Mutex = Mutex() + fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -198,7 +201,7 @@ object ChatModel { } } - suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) { + suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { // update previews val i = getChatIndex(cInfo.id) val chat: Chat @@ -221,10 +224,11 @@ object ChatModel { } else { addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) } - // add to current chat - if (chatId.value == cInfo.id) { - Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") - withContext(Dispatchers.Main) { + Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") + withContext(Dispatchers.Main) { + // add to current chat + if (chatId.value == cInfo.id) { + Log.d(TAG, "TODOCHAT: addChatItem: chatIds are equal, size ${chatItems.size}") // Prevent situation when chat item already in the list received from backend if (chatItems.none { it.id == cItem.id }) { if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { @@ -238,7 +242,7 @@ object ChatModel { } } - suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean { + suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { // update previews val i = getChatIndex(cInfo.id) val chat: Chat @@ -258,10 +262,10 @@ object ChatModel { addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) res = true } - // update current chat - return if (chatId.value == cInfo.id) { - Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") - withContext(Dispatchers.Main) { + Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") + return withContext(Dispatchers.Main) { + // update current chat + if (chatId.value == cInfo.id) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem @@ -272,15 +276,15 @@ object ChatModel { Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") true } + } else { + res } - } else { - res } } suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) { - if (chatId.value == cInfo.id) { - withContext(Dispatchers.Main) { + withContext(Dispatchers.Main) { + if (chatId.value == cInfo.id) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 3644268ba3..dc2c3c0f25 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -4,6 +4,7 @@ import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import chat.simplex.common.model.ChatModel.updatingChatsMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -16,6 +17,7 @@ import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings import kotlinx.coroutines.* +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.* @@ -349,8 +351,10 @@ object ChatController { startReceiver() Log.d(TAG, "startChat: started") } else { - val chats = apiGetChats() - chatModel.updateChats(chats) + updatingChatsMutex.withLock { + val chats = apiGetChats() + chatModel.updateChats(chats) + } Log.d(TAG, "startChat: running") } } catch (e: Error) { @@ -384,8 +388,10 @@ object ChatController { suspend fun getUserChatData() { chatModel.userAddress.value = apiGetUserAddress() chatModel.chatItemTTL.value = getChatItemTTL() - val chats = apiGetChats() - chatModel.updateChats(chats) + updatingChatsMutex.withLock { + val chats = apiGetChats() + chatModel.updateChats(chats) + } } private fun startReceiver() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index fa0f8f54d1..0cca354746 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -20,11 +20,13 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.res.MR +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.* import java.io.* import java.net.URI @@ -620,8 +622,10 @@ private fun afterSetCiTTL( appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath) withApi { try { - val chats = m.controller.apiGetChats() - m.updateChats(chats) + updatingChatsMutex.withLock { + val chats = m.controller.apiGetChats() + m.updateChats(chats) + } } catch (e: Exception) { Log.e(TAG, "apiGetChats error: ${e.message}") } From fe6c65f75cdb8c9b5dd4e8c29983fc28431fd62b Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 12 Oct 2023 17:19:19 +0300 Subject: [PATCH 068/294] rfc: remote profile (#3051) * Add session UX for mobile and desktop * Resolve some feedback * Resolve more feedback Add QR note for desktops. Add TLS handshake notice. * Add details --- docs/rfcs/2023-09-12-remote-profile.md | 200 +++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/rfcs/2023-09-12-remote-profile.md diff --git a/docs/rfcs/2023-09-12-remote-profile.md b/docs/rfcs/2023-09-12-remote-profile.md new file mode 100644 index 0000000000..9a41d55d5a --- /dev/null +++ b/docs/rfcs/2023-09-12-remote-profile.md @@ -0,0 +1,200 @@ +# Remote profile + +## Problem + +Users want their desktop client to be in sync with profiles at their main device (presumably a mobile phone). +Due to distributed nature of SimpleX chat and comprehensive encryption it is difficult to maintain up to date multi-way synchronized presentation between devices. + +## Solution + +A typical (and expected) solution for this is running a server on a master device which will handle all the communication. +Then, additional "thin" client(s) would be able to present an interface, delegating everything else to the main. + +Fortunately, we already have such a protocol in our clients. +CLI and GUI run a text+json RPC protocol to their chat core. +CLI has a WebSocket server for it that facilitates making custom clients and bots – it won't be usable here though. + +We can run this protocol over a secure channel designed specifically for this problem. + +Then we can tweak clients to use this protocol instead of regular "local" profiles. + +## Session lifecycle + +For the sake of grounding and familiarity the roles are: +* "Mobile": a master device which stores data and does the communication. +* "Desktop": UI client attached to the master. + +1. Discovery: a user wants to attach a desktop client to their mobile. +2. Handshake: desktop and mobile establish a secure duplex session. +3. Activity: desktop sends requests and receives events from mobile and updates its presentation. +4. Restart: desktop should be able to re-eastablish channel unattended in the case of network winking out for a while. +5. Disposal: mobile can terminate the link and permanently dispose the established session. + +[![](https://mermaid.ink/img/pako:eNq1Vs2O2jAQfpVRTq3EvgCqVtomrZZDKhWKtAcuxh7AxbFT27BCq5X2QdqX2yfpODHGQOitJ4jn88w3P98kLwU3Aotx4fDXDjXHSrK1Zc1CAzDujYVqDsxBhW7rTRuOW2a95LJl2kM1yYwwn1zZy9xeGouXiLpD1GYpFQ4DJhmgj9ATq-cnw0KHc208gtljID0i-xgq6Xg4OARzNb-7v68mY5iGXJ0HxzwqJelSa82qc0OoSUCVY5gI1F76A9gefhGBEJ-tYYIz8iQjNrKTe_JMkM5fGaMmf4J5dorUs2wVOyQv8H0KoS0BVAfSNV2fcabPDOF2XYZs1tJ5onRKZ5BOXQ7X6JFp4TZsi4ltnWe_PCbZBS0vi8M3TCnU6xu3LbrWaNdZTevhq7RULi8bhE0eN7qus-5wo1fSNsxLoyOkS5kov7_9LnsrkAcr9RqIBfgNgvO71Qqepd-AwL3kCE9PT4vi_e1P74Mp3__JSkvuHjjH1idUVt6a2e2pM5EUioQ7VmSGWgxwRuVwKOAUfyIfDFihQuqRjW1FcdbOgZiMZKtUHlN39OKPwIuJvB6BhwAI6X0wWh26OjZMalDGtB_PrwT68wksleFbFJ-W9t4bkg_uiWJ4cj4ECnVQknt3PoGdeoPTnifJ_JhSUl25YT5XXGc8jtUsTfgFIuNHsJ1-tqwNo-mBtXLUjZrCEdAPBNN1ITPHp4FNsYN8I7HcyrT4hyfiJFeSZ3259HaNuNm5vnTXnZtSEWhZ3to7_0nJSUTJS6-f_jAflhH0C9ftSPEsjlmXYtJF8tFL4paPsCSNI4Gjc7FeXZluLn5CMxXZntpyJ7X0kp7E2SuBFFmhZ3xz2qJRm2kyf6BtpA4FziikOlV4FOyZXEOa8GicP4-bb4IbcbN9MOg5le02r-6to_dMSZGZoZNoNmZZ9SajvhENPSZ8_746bvBv5mhwNBNhTdD6vWpaUEJ45atAXGPUeT7QZWJQjIqGcmBS0HfISzheFLSCGlwUY_oraAEvioV-JRzbeTM7aF6MV4wqOyp2bUgtfrRcnH4Rkr4T4uHrX2MK93E?type=png)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNq1Vs2O2jAQfpVRTq3EvgCqVtomrZZDKhWKtAcuxh7AxbFT27BCq5X2QdqX2yfpODHGQOitJ4jn88w3P98kLwU3Aotx4fDXDjXHSrK1Zc1CAzDujYVqDsxBhW7rTRuOW2a95LJl2kM1yYwwn1zZy9xeGouXiLpD1GYpFQ4DJhmgj9ATq-cnw0KHc208gtljID0i-xgq6Xg4OARzNb-7v68mY5iGXJ0HxzwqJelSa82qc0OoSUCVY5gI1F76A9gefhGBEJ-tYYIz8iQjNrKTe_JMkM5fGaMmf4J5dorUs2wVOyQv8H0KoS0BVAfSNV2fcabPDOF2XYZs1tJ5onRKZ5BOXQ7X6JFp4TZsi4ltnWe_PCbZBS0vi8M3TCnU6xu3LbrWaNdZTevhq7RULi8bhE0eN7qus-5wo1fSNsxLoyOkS5kov7_9LnsrkAcr9RqIBfgNgvO71Qqepd-AwL3kCE9PT4vi_e1P74Mp3__JSkvuHjjH1idUVt6a2e2pM5EUioQ7VmSGWgxwRuVwKOAUfyIfDFihQuqRjW1FcdbOgZiMZKtUHlN39OKPwIuJvB6BhwAI6X0wWh26OjZMalDGtB_PrwT68wksleFbFJ-W9t4bkg_uiWJ4cj4ECnVQknt3PoGdeoPTnifJ_JhSUl25YT5XXGc8jtUsTfgFIuNHsJ1-tqwNo-mBtXLUjZrCEdAPBNN1ITPHp4FNsYN8I7HcyrT4hyfiJFeSZ3259HaNuNm5vnTXnZtSEWhZ3to7_0nJSUTJS6-f_jAflhH0C9ftSPEsjlmXYtJF8tFL4paPsCSNI4Gjc7FeXZluLn5CMxXZntpyJ7X0kp7E2SuBFFmhZ3xz2qJRm2kyf6BtpA4FziikOlV4FOyZXEOa8GicP4-bb4IbcbN9MOg5le02r-6to_dMSZGZoZNoNmZZ9SajvhENPSZ8_746bvBv5mhwNBNhTdD6vWpaUEJ45atAXGPUeT7QZWJQjIqGcmBS0HfISzheFLSCGlwUY_oraAEvioV-JRzbeTM7aF6MV4wqOyp2bUgtfrRcnH4Rkr4T4uHrX2MK93E) + +### Discovery + +The expected flow is desktop initiates the discovery by generating OOB key data and shows a QR code for mobile to scan. +The mobile then scans that QR code, decodes the "attachment request" and spins up a network server. + +There is a problem here, that the desktop doesn't know where its mobile actually located. + +This can be solved in a few different ways: + +1. The desktop starts a server and encodes its local IP in the QR. Mobile then connects to it. +2. The desktop encodes its local IP, but mobile only does a minimal client legwork, only to signal its actual location. Then the sides flip. + * The legwork may entail sending UDP datagram to desktop IP with an IP of its own. + * Another option is to use a TCP "nanoprotocol" of sending a `host:port` line. +3. The mobile may start announcing itself with UDP broadcasts for the duration of the phase (bluetooth-style) using information in the QR code. +4. A desktop may create a temporary SMP queue and show its address. The mobile then submits its server data to it. + +Another option is to run the server on desktop and have mobile discover it with the help of QR code to get server identity and keys and then on the network via some protocol. Using a fixed address is suboptimal as most networks have dynamic IPs. + +### Handshake + +The aim of this phase is to establish a TLS+cryptobox session. + +TLS could be complex as we need to generate self-signed certificates on desktop (if it acts like a server). A plaintext ws connection with cryptobox encryption could be sufficient initially? + +TBD + +### Activity + +The desktop starts its chat core with a special parameter to signal that it should be using the session instead of its regular "local" database. This can be determined per user profile. + +Other than that, the client behaves like it would do with a local chat state. +Its chat core being handed a socket uses it to relay the chat protocol data. + +The mobile, starts replaying the commands it had received on its state, maintaining a single point of truth. +When a mobile receives events or replies, it mirrors them to the attached session. + +Only a subset of the chat API should be available this way. +Requests like `/_stop` or `/_db delete` should be filtered out and ignored. + +Some of the relayed commands (e.g. `/_read chat` or `/_reaction`) the mobile should apply to its own state too. + +A simpler solution could be that while desktop client is connected mobile UI is locked. When the session terminates, mobile UI gets unlocked and refreshed. + +> A tweak in protocol that would reply with an event like "accepted read of X up to Y" may remove the need for such matching and interpretation. + +### Restart + +It would be annoying to users if walking to another room and loosing WiFi connection for a few seconds would result in another QR dance. + +Therefore, the non-ephemeral part of handshake material should be reused for reconnects. + +TBD + +### Disposal + +The session may have a lifetime that a desktop or a mobile may stipulate while preparing a session. +Alternatively a mobile (or a desktop, why not) may signal that they're done here and no further activity should be going with the session parameters. + +## Proposed UX flow + +> For now, desktop and mobile roles are mutually exclusive. +> Mobile device can only host remote session, while desktop devices can only remote-control. + +### On a mobile device + +1. A user opens sidebar and clicks "use from desktop" in the "You" section, starting remote controller discovery. + * When this happens for the first time, the user must set the mobile device name, pre-filled from system device name if possible. + * UI enters "Waiting for desktop" window, which collects all the broadcasts received so far. + - + * Discovery process starts UDP broadcast listener on application port (5226). + * A datagram containing remote controller fingerprint is checked against a list of pre-registered controller devices. + - If the datagram contains no valid fingerprint, it is ignored. + - For unknown/new broadcasts a fingerprint is displayed instead. + - If the device is already known, the host establishes connection and UI transitions into "connection status" window. +2. Clicking on unknown device fingerprint in the list starts OOB handshake. + * UI enters "New device" window, displaying a fingerprint and asking to scan a QR code (or paste a link, like in the contact screen). + * A OOB data from the QR/link contains remote controller fingerprint and remote display name, which is stored in device DB. + - The OOB fingerprint must match the announce. + * Accepting the OOB automatically triggers remote controller connection and transitions UI to "connection status" window. +3. A remote session initiated with a known device, or as a result of OOB exchange. + * A "connection status" window shows registered display name and current session status. + * Chat controller attempts to establish a remote session. + - The source adddress of the datagram is used to initiate TCP connection. + - A TCP connection is made to the address discovered. + - A TLS connection is instantiated, checking for remote CA fingerprint matches the previously established. + - A HTTP2 server is started on the mobile side of the TLS connection. + - The remote controller connects and subscribes for its output queue, marking the session established. + * For the duration of the remote session, the UI remains in the status window, preventing user interaction. + - This restriction may be lifted later. + +At any time a user may click on a "cancel" button and return to the main UI. +That should fully re-initialise UI state. + +In the "Network & servers" section of "Settings", there is an item to list all the registered remote controllers with buttons attached to *dispose* them one by one. +*Disposing* a remote controller means its entry will be removed from database. +Future connection attempts from a disposed device would be treated exactly as from a previously-unknown device. + +### On a desktop device + +1. A user opens sidebar and clicks "connect to mobile" in the "You" section. + * UI enters a "Select remote host" window asking user to pick an existing connection profile or generate a new one. + * When a new connection profile is requested by a user, a private key is generated and a new X509 root CA certificate is produced and stored in device DB. Then the desktop proceeds to the connection screen. +2. Clicking on an existing connection profile transitions UI to "connecting to remote host" window. + * For a first-time connection a QR code / link is presented, containing the fingerprint of the CA stored for the selected profile. + - After first time the QR code is hidden until a subdued "show QR code" button is clicked. This is to prevent user confusion that they have to scan the code every time. + * A new session certificate is derived from the CA. + * A TLS server is started using ephemeral session certificate. + - TLS handshake is used to authenticate desktop to a connecting mobile, proving that the announcer is indeed owns the key with the fingerprint received OOB by mobile. See below for a case for mutual authentication. + * A periodic UDP broadcast on port 5226 is started, sending the fingerprint. +3. When an incoming connection is established the UI transitions to "connected to remote host" window. + * The announcer is terminated and TCP server stops accepting new connections. + * Desktop chat controller establishes a remote session over the tls session. + * UI transitions to the "remote host" mode, shunting local profiles into background while keeping notifications coming. +4. A user may open sidebar and click "disconnect from mobile" to close the session and return to local mode. + * That should fully re-initialise UI state. + +In the "Network & servers" section of "Settings", there is an item to list all the registered remote hosts with buttons attached to *dispose* them one by one. +*Disposing* a remote host means its entry will be removed from database and any associated files deleted (photos, voice messages, transferred files etc). + +## Caveats + +A public WiFi spot (or a specially configured home AP) may prohibit clients to connect with each other, denying them link-local connection. +n such an event, an alternative transports may be considered: +- Bluetooth link. +- USB tethering that presents an ethernet device. +- The usual NAT traversal techniques. +- Running localnet-providing VPNs. +- Routing chat traffic via SMP queues. + +Application chat traffic may end up too chatty for the link. +This may result in large power drain for both sides or unpleasant latency. +Compression protocols may be used to mitigate this. +Since we know that chat API is text+JSON Zstd compression with pre-shared dictionary may provide huge traffic savings with minimal CPU effort. + +There is a threat, that a device in the broadcast range may intercept discovery datagrams and eagerly connect to their sources. +In the case of such a "honeypot", a desktop may be tricked into receiving arbitrary contacts, messages and files from the remote host. +Some mitigations are possible for authenticating a remote host (like using OOB token as a cookie or exchanging it for a TLS client certificate). +This is intentionally left out of scope for now, until the "remote profiles" system is audited, to be resolved in a wider context. + +Requesting a list of IP addresses is problematic. +A shady app permission is required from the OS (ACCESS_NETWORK_STATE on Android). +And then the app needs to sort through all the found interfaces and guess which one would be accessible. + +## "Should-works" + +File transfer appears to be running within the chat protocol. + +UI assumes that files are available in a local storage, the access to files is not part of chat RPC. This complicates things a lot. + +Attaching multiple sessions appears to be realistic without extensive modifications. + +A headless client with a global address (e.g. VPN or TOR) may be used in a manner of IRC bouncers. + +This may also allow "thin" mobile clients (cf. traffic concerns) and browser apps. + +A backup system may be implemented by attaching a headless app to a bouncer as one of the sessions. + +The unauthenticated remote host can be considered a feature. +A use case for that may be something like a "dead drop" host that wakes up in response to any discovery broadcast. + +## Unresolved questions + +- What to do with WebRTC/calls? +- Do we want attaching only to a subset of profiles? +- Do we want a client to mix remote and local profiles? +- Do we want M-to-N sessions? (follows naturally from the previous two) From 675fc19745960db809e77ced0554b6947ae3187b Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 13 Oct 2023 07:05:06 +0800 Subject: [PATCH 069/294] rfc: desktop calls (#3208) * rfc: desktop calls * errors * html * link * screen sharing * additions * addition * correction --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- docs/rfcs/2023-10-12-desktop-calls.md | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/rfcs/2023-10-12-desktop-calls.md diff --git a/docs/rfcs/2023-10-12-desktop-calls.md b/docs/rfcs/2023-10-12-desktop-calls.md new file mode 100644 index 0000000000..c956ed0115 --- /dev/null +++ b/docs/rfcs/2023-10-12-desktop-calls.md @@ -0,0 +1,34 @@ +# Desktop calls + +To make audio and video calls on desktop there are some options: +- adapt [libwebrtc](webrtc.googlesource.com/) from Google which would be the most reliable, performant and seamless solution; +- include some kind of WebView to the app via libraries; +- implement a signaling server in the app and let users to use HTML page with WebRTC code that already exist for Android. + +## WebRTC lib + +To adapt libwebrtc we need to make SDK that is compatible with Java (JNI layer + desktop implementation of VideoCodecs and other features). There are two SDKs exist already: for Android and for Objective-C. Making Android SDK compatible with Java-only SDK gives a lot of problems and requires to have 5+ professional C++ developers with weeks/months for developing. Which is not something good. + +Another considered option was adopting Jitsi Java SDK, but it's state is not very clear, and in any case it is much more effort. + +## WebView + +Including WebView is possible but requires a lot of megabytes of storage to waste on such libs. Because only Chromium-like WebViews support WebRTC features. Which means 100+ MB to the package on top of 200+ MB now. + +## Standalone browser + WebRTC HTML page + +The last solution is what can give the most useful result: the same package size as before + already existent code which can be reused with small modifications + quality of result will depend on Chromium/Firefox/Safari devs (which is good, since they are interested in making all features working for everyone). + +# Details of implementation + +Since the code for WebView has already written (https://github.com/simplex-chat/simplex-chat/tree/0e4376bada2d0c4ec2ade7f30b0048dc8b13abd8/apps/multiplatform/android/src/main/assets/www) it can be used to make calls on a desktop browser too. The only differences are these: +- UI needs to be changed - buttons controlling calls should be added. For example, end call, disable camera/mic. This will be added to separate HTML and JS files that would communication with the existing WebRTC JS code via the existing functional API. +- signaling websocket server should be started in order to allow Haskell backend to talk with HTML page and to exchange messages between all parties. It is bidirectional communication between server and webpage since both parties have to send messages to each other. + +There is no need to have TLS-secured connection between server and webpage since it's only used locally. And browser will not be happy with self-signed certificate too. + +After accepting the call, webpage will be opened in the default browser. URL will look like: `https://localhost:123/simplex/call/` (to reduce questions and to namespace other files - the main UI page will be index.html, that would load via folder path) After that internal machinary will connect both parties together. Same as on Android but with a different signaling channel, in this case it's websockets. + +Ending the call by the user will also send a new action to signaling server to allow the backend to notify other party. + +The solution will also allow to make screen sharing in the future that will be supported on every OS and graphics environment where the user's browser supports it. \ No newline at end of file From ab290fb0683047f706f49d5b35052c44873518f7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 11:51:01 +0100 Subject: [PATCH 070/294] core: track network statuses, use in commands/events (#3211) * core: track network statuses, use in commands/events * ui types, test * remove comment --- apps/ios/Shared/Model/ChatModel.swift | 38 ---------- apps/ios/Shared/Model/SimpleXAPI.swift | 27 +++++--- apps/ios/SimpleXChat/APITypes.swift | 59 ++++++++++++++-- apps/ios/SimpleXChat/ChatTypes.swift | 12 +++- .../chat/simplex/common/model/ChatModel.kt | 20 ++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 39 ++++++++--- src/Simplex/Chat.hs | 69 ++++++++++++++----- src/Simplex/Chat/Controller.hs | 15 +++- src/Simplex/Chat/Mobile.hs | 3 +- src/Simplex/Chat/Types.hs | 37 +++++++++- src/Simplex/Chat/View.hs | 10 ++- tests/ChatTests/Direct.hs | 16 +++++ tests/MobileTests.hs | 32 ++++----- 13 files changed, 275 insertions(+), 102 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a1ec2f8f53..ec1a567420 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -688,41 +688,3 @@ final class Chat: ObservableObject, Identifiable { public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } - -enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(String) - - var statusString: LocalizedStringKey { - get { - switch self { - case .connected: return "connected" - case .error: return "error" - default: return "connecting" - } - } - } - - var statusExplanation: LocalizedStringKey { - get { - switch self { - case .connected: return "You are connected to the server used to receive messages from this contact." - case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: return "Trying to connect to the server used to receive messages from this contact." - } - } - } - - var imageName: String { - get { - switch self { - case .unknown: return "circle.dotted" - case .connected: return "circle.fill" - case .disconnected: return "ellipsis.circle.fill" - case .error: return "exclamationmark.circle.fill" - } - } - } -} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 85e66e893d..e4f64ee977 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -944,6 +944,12 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } } +func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { + let r = chatSendCmdSync(.apiGetNetworkStatuses) + if case let .networkStatuses(_, statuses) = r { return statuses } + throw r +} + func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async { do { if chat.chatStats.unreadCount > 0 { @@ -1348,13 +1354,6 @@ func processReceivedMsg(_ res: ChatResponse) async { await updateContactsStatus(contactRefs, status: .connected) case let .contactsDisconnected(_, contactRefs): await updateContactsStatus(contactRefs, status: .disconnected) - case let .contactSubError(user, contact, chatError): - await MainActor.run { - if active(user) { - m.updateContact(contact) - } - processContactSubError(contact, chatError) - } case let .contactSubSummary(_, contactSubscriptions): await MainActor.run { for sub in contactSubscriptions { @@ -1369,6 +1368,18 @@ func processReceivedMsg(_ res: ChatResponse) async { } } } + case let .networkStatus(status, connections): + await MainActor.run { + for cId in connections { + m.networkStatuses[cId] = status + } + } + case let .networkStatuses(statuses): () + await MainActor.run { + for s in statuses { + m.networkStatuses[s.agentConnId] = s.networkStatus + } + } case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem @@ -1649,7 +1660,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" default: err = String(describing: chatError) } - m.setContactNetworkStatus(contact, .error(err)) + m.setContactNetworkStatus(contact, .error(connectionError: err)) } func refreshCallInvitations() throws { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d65b9b3283..d29e2481d5 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -110,6 +110,7 @@ public enum ChatCommand { case apiEndCall(contact: Contact) case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) + case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) @@ -241,6 +242,7 @@ public enum ChatCommand { case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" + case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" case let .receiveFile(fileId, encrypted, inline): @@ -356,6 +358,7 @@ public enum ChatCommand { case .apiEndCall: return "apiEndCall" case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiCallStatus: return "apiCallStatus" + case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" case .apiChatRead: return "apiChatRead" case .apiChatUnread: return "apiChatUnread" case .receiveFile: return "receiveFile" @@ -480,11 +483,14 @@ public enum ChatResponse: Decodable, Error { case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef) case contactUpdated(user: UserRef, toContact: Contact) + // TODO remove events below case contactsSubscribed(server: String, contactRefs: [ContactRef]) case contactsDisconnected(server: String, contactRefs: [ContactRef]) - case contactSubError(user: UserRef, contact: Contact, chatError: ChatError) case contactSubSummary(user: UserRef, contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(user: UserRef, groupInfo: GroupInfo) + // TODO remove events above + case networkStatus(networkStatus: NetworkStatus, connections: [String]) + case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case groupSubscribed(user: UserRef, groupInfo: GroupRef) case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) case groupEmpty(user: UserRef, groupInfo: GroupInfo) case userContactLinkSubscribed @@ -620,8 +626,9 @@ public enum ChatResponse: Decodable, Error { case .contactUpdated: return "contactUpdated" case .contactsSubscribed: return "contactsSubscribed" case .contactsDisconnected: return "contactsDisconnected" - case .contactSubError: return "contactSubError" case .contactSubSummary: return "contactSubSummary" + case .networkStatus: return "networkStatus" + case .networkStatuses: return "networkStatuses" case .groupSubscribed: return "groupSubscribed" case .memberSubErrors: return "memberSubErrors" case .groupEmpty: return "groupEmpty" @@ -757,8 +764,9 @@ public enum ChatResponse: Decodable, Error { case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))") case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) + case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" + case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) @@ -1181,6 +1189,49 @@ public struct KeepAliveOpts: Codable, Equatable { public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4) } +public enum NetworkStatus: Decodable, Equatable { + case unknown + case connected + case disconnected + case error(connectionError: String) + + public var statusString: LocalizedStringKey { + get { + switch self { + case .connected: return "connected" + case .error: return "error" + default: return "connecting" + } + } + } + + public var statusExplanation: LocalizedStringKey { + get { + switch self { + case .connected: return "You are connected to the server used to receive messages from this contact." + case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." + default: return "Trying to connect to the server used to receive messages from this contact." + } + } + } + + public var imageName: String { + get { + switch self { + case .unknown: return "circle.dotted" + case .connected: return "circle.fill" + case .disconnected: return "ellipsis.circle.fill" + case .error: return "exclamationmark.circle.fill" + } + } + } +} + +public struct ConnNetworkStatus: Decodable { + public var agentConnId: String + public var networkStatus: NetworkStatus +} + public struct ChatSettings: Codable { public var enableNtfs: MsgFilter public var sendRcpts: Bool? diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 37e4f0316a..caaccb5454 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1729,6 +1729,11 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { ) } +public struct GroupRef: Decodable { + public var groupId: Int64 + var localDisplayName: GroupName +} + public struct GroupProfile: Codable, NamedChat { public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) { self.displayName = displayName @@ -1871,6 +1876,11 @@ public struct GroupMemberRef: Decodable { var profile: Profile } +public struct GroupMemberIds: Decodable { + var groupMemberId: Int64 + var groupId: Int64 +} + public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { case observer = "observer" case member = "member" @@ -1963,7 +1973,7 @@ public enum InvitedBy: Decodable { } public struct MemberSubError: Decodable { - var member: GroupMember + var member: GroupMemberIds var memberError: ChatError } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ac5f9fb8a5..a4b76cc79d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -789,16 +789,19 @@ sealed class NetworkStatus { val statusExplanation: String get() = when (this) { is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) - is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), error) + is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError) else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) } @Serializable @SerialName("unknown") class Unknown: NetworkStatus() @Serializable @SerialName("connected") class Connected: NetworkStatus() @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() - @Serializable @SerialName("error") class Error(val error: String): NetworkStatus() + @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus() } +@Serializable +data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus) + @Serializable data class Contact( val contactId: Long, @@ -1051,6 +1054,9 @@ data class GroupInfo ( } } +@Serializable +data class GroupRef(val groupId: Long, val localDisplayName: String) + @Serializable data class GroupProfile ( override val displayName: String, @@ -1159,11 +1165,17 @@ data class GroupMember ( data class GroupMemberSettings(val showMessages: Boolean) {} @Serializable -class GroupMemberRef( +data class GroupMemberRef( val groupMemberId: Long, val profile: Profile ) +@Serializable +data class GroupMemberIds( + val groupMemberId: Long, + val groupId: Long +) + @Serializable enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons @@ -1257,7 +1269,7 @@ class LinkPreview ( @Serializable class MemberSubError ( - val member: GroupMember, + val member: GroupMemberIds, val memberError: ChatError ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index dc2c3c0f25..e19520093c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1082,6 +1082,13 @@ object ChatController { return r is CR.CmdOk } + suspend fun apiGetNetworkStatuses(): List? { + val r = sendCmd(CC.ApiGetNetworkStatuses()) + if (r is CR.NetworkStatuses) return r.networkStatuses + Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { val r = sendCmd(CC.ApiChatRead(type, id, range)) if (r is CR.CmdOk) return true @@ -1425,12 +1432,6 @@ object ChatController { } is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) - is CR.ContactSubError -> { - if (active(r.user)) { - chatModel.updateContact(r.contact) - } - processContactSubError(r.contact, r.chatError) - } is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { @@ -1444,6 +1445,16 @@ object ChatController { } } } + is CR.NetworkStatusResp -> { + for (cId in r.connections) { + chatModel.networkStatuses[cId] = r.networkStatus + } + } + is CR.NetworkStatuses -> { + for (s in r.networkStatuses) { + chatModel.networkStatuses[s.agentConnId] = s.networkStatus + } + } is CR.NewChatItem -> { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem @@ -1915,6 +1926,7 @@ sealed class CC { class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() class ApiEndCall(val contact: Contact): CC() class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() + class ApiGetNetworkStatuses(): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() @@ -2024,6 +2036,7 @@ sealed class CC { is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" + is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") @@ -2120,6 +2133,7 @@ sealed class CC { is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" is ApiEndCall -> "apiEndCall" is ApiCallStatus -> "apiCallStatus" + is ApiGetNetworkStatuses -> "apiGetNetworkStatuses" is ApiChatRead -> "apiChatRead" is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" @@ -3333,11 +3347,14 @@ sealed class CR { @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() + // TODO remove below @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactSubError") class ContactSubError(val user: UserRef, val contact: Contact, val chatError: ChatError): CR() @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List): CR() - @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupInfo): CR() + // TODO remove above + @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR() + @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR() + @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupRef): CR() @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List): CR() @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR() @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() @@ -3467,8 +3484,9 @@ sealed class CR { is ContactUpdated -> "contactUpdated" is ContactsSubscribed -> "contactsSubscribed" is ContactsDisconnected -> "contactsDisconnected" - is ContactSubError -> "contactSubError" is ContactSubSummary -> "contactSubSummary" + is NetworkStatusResp -> "networkStatus" + is NetworkStatuses -> "networkStatuses" is GroupSubscribed -> "groupSubscribed" is MemberSubErrors -> "memberSubErrors" is GroupEmpty -> "groupEmpty" @@ -3596,8 +3614,9 @@ sealed class CR { is ContactUpdated -> withUser(user, json.encodeToString(toContact)) is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}") is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) + is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections" + is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses)) is GroupSubscribed -> withUser(user, json.encodeToString(group)) is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) is GroupEmpty -> withUser(user, json.encodeToString(group)) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b7421d9366..56077c3bf6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -143,7 +143,8 @@ defaultChatConfig = initialCleanupManagerDelay = 30 * 1000000, -- 30 seconds cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds - ciExpirationInterval = 30 * 60 * 1000000 -- 30 minutes + ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes + coreApi = False } _defaultSMPServers :: NonEmpty SMPServerWithAuth @@ -195,6 +196,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen idsDrg <- newTVarIO =<< liftIO drgNew inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize + connNetworkStatuses <- atomically TM.empty subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO sndFiles <- newTVarIO M.empty @@ -221,6 +223,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen idsDrg, inputQ, outputQ, + connNetworkStatuses, subscriptionMode, chatLock, sndFiles, @@ -1086,6 +1089,8 @@ processChatCommand = \case user <- getUserByContactId db contactId contact <- getContact db user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} + APIGetNetworkStatuses -> withUser $ \_ -> + CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call @@ -1688,6 +1693,8 @@ processChatCommand = \case (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode -- [incognito] reuse membership incognito profile ct <- withStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode + -- TODO not sure it is correct to set connections status here? + setContactNetworkStatus ct NSConnected pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do @@ -2627,6 +2634,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce +-- TODO possibly, we could either disable these events or replace with less noisy for API contactLinkSubsToView rs ucs groupSubsToView rs gs ms ce sndFileSubsToView rs sfts @@ -2687,12 +2695,30 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do let connIds = map aConnId' pcs pure (connIds, M.fromList $ zip connIds pcs) contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> Bool -> m () - contactSubsToView rs cts ce = do - toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs - when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors + contactSubsToView rs cts ce = ifM (asks $ coreApi . config) notifyAPI notifyCLI where - cRs = resultsFor rs cts - cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs + notifyCLI = do + let cRs = resultsFor rs cts + cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs + toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs + when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors + notifyAPI = do + let statuses = M.foldrWithKey' addStatus [] cts + chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) + toView $ CRNetworkStatuses (Just user) $ map (uncurry ConnNetworkStatus) statuses + where + addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] + addStatus connId ct = + let ns = (contactAgentConnId ct, netStatus $ resultErr connId rs) + in (ns :) + netStatus :: Maybe ChatError -> NetworkStatus + netStatus = maybe NSConnected $ NSError . errorNetworkStatus + errorNetworkStatus :: ChatError -> String + errorNetworkStatus = \case + ChatErrorAgent (BROKER _ NETWORK) _ -> "network" + ChatErrorAgent (SMP SMP.AUTH) _ -> "contact deleted" + e -> show e +-- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> m () contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m () @@ -2742,12 +2768,12 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do resultsFor rs = M.foldrWithKey' addResult [] where addResult :: ConnId -> a -> [(a, Maybe ChatError)] -> [(a, Maybe ChatError)] - addResult connId = (:) . (,err) - where - err = case M.lookup connId rs of - Just (Left e) -> Just $ ChatErrorAgent e Nothing - Just _ -> Nothing - _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId + addResult connId = (:) . (,resultErr connId rs) + resultErr :: ConnId -> Map ConnId (Either AgentErrorType ()) -> Maybe ChatError + resultErr connId rs = case M.lookup connId rs of + Just (Left e) -> Just $ ChatErrorAgent e Nothing + Just _ -> Nothing + _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId cleanupManager :: forall m. ChatMonad m => m () cleanupManager = do @@ -2892,16 +2918,22 @@ processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h DISCONNECT p h -> hostEvent $ CRHostDisconnected p h - DOWN srv conns -> serverEvent srv conns CRContactsDisconnected - UP srv conns -> serverEvent srv conns CRContactsSubscribed + DOWN srv conns -> serverEvent srv conns NSDisconnected CRContactsDisconnected + UP srv conns -> serverEvent srv conns NSConnected CRContactsSubscribed SUSPENDED -> toView CRChatSuspended DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId where hostEvent :: ChatResponse -> m () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv conns event = do - cs <- withStore' (`getConnectionsContacts` conns) - toView $ event srv cs + serverEvent srv conns nsStatus event = ifM (asks $ coreApi . config) notifyAPI notifyCLI + where + notifyAPI = do + let connIds = map AgentConnId conns + chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds + toView $ CRNetworkStatus nsStatus connIds + notifyCLI = do + cs <- withStore' (`getConnectionsContacts` conns) + toView $ event srv cs processAgentMsgSndFile :: forall m. ChatMonad m => ACorrId -> SndFileId -> ACommand 'Agent 'AESndFile -> m () processAgentMsgSndFile _corrId aFileId msg = @@ -3188,6 +3220,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> do -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + setContactNetworkStatus ct NSConnected toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct) $ createFeatureEnabledItems ct when (contactConnInitiated conn) $ do @@ -3762,6 +3795,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> m () notifyMemberConnected gInfo m ct_ = do memberConnectedChatItem gInfo m + mapM_ (`setContactNetworkStatus` NSConnected) ct_ toView $ CRConnectedToGroupMember user gInfo m ct_ probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () @@ -5569,6 +5603,7 @@ chatCommandP = "/_call end @" *> (APIEndCall <$> A.decimal), "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, + "/_network_statuses" $> APIGetNetworkStatuses, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index af9f34d2d4..2704c2721f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -32,6 +32,7 @@ import Data.Char (ord) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) @@ -124,7 +125,8 @@ data ChatConfig = ChatConfig initialCleanupManagerDelay :: Int64, cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, - ciExpirationInterval :: Int64 -- microseconds + ciExpirationInterval :: Int64, -- microseconds + coreApi :: Bool } data DefaultAgentServers = DefaultAgentServers @@ -164,6 +166,7 @@ data ChatController = ChatController idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe CorrId, ChatResponse), + connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, sndFiles :: TVar (Map Int64 Handle), @@ -251,6 +254,7 @@ data ChatCommand | APIEndCall ContactId | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus + | APIGetNetworkStatuses | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias @@ -528,6 +532,8 @@ data ChatResponse | CRContactSubError {user :: User, contact :: Contact, chatError :: ChatError} | CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} | CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} + | CRNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} + | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} @@ -1044,6 +1050,13 @@ chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m () chatWriteVar f value = asks f >>= atomically . (`writeTVar` value) {-# INLINE chatWriteVar #-} +chatModifyVar :: ChatMonad' m => (ChatController -> TVar a) -> (a -> a) -> m () +chatModifyVar f newValue = asks f >>= atomically . (`modifyTVar'` newValue) +{-# INLINE chatModifyVar #-} + +setContactNetworkStatus :: ChatMonad' m => Contact -> NetworkStatus -> m () +setContactNetworkStatus ct = chatModifyVar connNetworkStatuses . M.insert (contactAgentConnId ct) + tryChatError :: ChatMonad m => m a -> m (Either ChatError a) tryChatError = tryAllErrors mkChatError {-# INLINE tryChatError #-} diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 0a970d2c8e..b444888145 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -169,7 +169,8 @@ defaultMobileConfig :: ChatConfig defaultMobileConfig = defaultChatConfig { confirmMigrations = MCYesUp, - logLevel = CLLError + logLevel = CLLError, + coreApi = True } getActiveUser_ :: SQLiteStore -> IO (Maybe User) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 83d0664a04..9992f96ab0 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -192,6 +192,9 @@ instance ToJSON Contact where contactConn :: Contact -> Connection contactConn Contact {activeConn} = activeConn +contactAgentConnId :: Contact -> AgentConnId +contactAgentConnId Contact {activeConn = Connection {agentConnId}} = agentConnId + contactConnId :: Contact -> ConnId contactConnId = aConnId . contactConn @@ -1140,13 +1143,16 @@ liveRcvFileTransferPath ft = fp <$> liveRcvFileTransferInfo ft fp RcvFileInfo {filePath} = filePath newtype AgentConnId = AgentConnId ConnId - deriving (Eq, Show) + deriving (Eq, Ord, Show) instance StrEncoding AgentConnId where strEncode (AgentConnId connId) = strEncode connId strDecode s = AgentConnId <$> strDecode s strP = AgentConnId <$> strP +instance FromJSON AgentConnId where + parseJSON = strParseJSON "AgentConnId" + instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1477,6 +1483,35 @@ serializeIntroStatus = \case textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode +data NetworkStatus + = NSUnknown + | NSConnected + | NSDisconnected + | NSError {connectionError :: String} + deriving (Eq, Ord, Show, Generic) + +netStatusStr :: NetworkStatus -> String +netStatusStr = \case + NSUnknown -> "unknown" + NSConnected -> "connected" + NSDisconnected -> "disconnected" + NSError e -> "error: " <> e + +instance FromJSON NetworkStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "NS" + +instance ToJSON NetworkStatus where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "NS" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "NS" + +data ConnNetworkStatus = ConnNetworkStatus + { agentConnId :: AgentConnId, + networkStatus :: NetworkStatus + } + deriving (Show, Generic, FromJSON) + +instance ToJSON ConnNetworkStatus where toEncoding = J.genericToEncoding J.defaultOptions + type CommandId = Int64 aCorrId :: CommandId -> ACorrId diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 86a10988d6..bcbc45f4eb 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -19,7 +19,7 @@ import Data.Char (isSpace, toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) -import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -210,6 +210,8 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks + CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] + CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRGroupInvitation u g -> ttyUser u [groupInvitation' g] CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g @@ -800,6 +802,12 @@ viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"] viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"] +viewNetworkStatuses :: [ConnNetworkStatus] -> [StyledString] +viewNetworkStatuses = map viewStatuses . L.groupBy ((==) `on` netStatus) . sortOn netStatus + where + netStatus ConnNetworkStatus {networkStatus} = networkStatus + viewStatuses ss@(s :| _) = plain $ show (L.length ss) <> " connections " <> netStatusStr (netStatus s) + viewUserJoinedGroup :: GroupInfo -> [StyledString] viewUserJoinedGroup g = case incognitoMembershipProfile g of diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index b4c3c53cd9..677bc4c09d 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -119,6 +119,8 @@ chatDirectTests = do testReqVRange vr11 supportedChatVRange testReqVRange vr11 vr11 it "update peer version range on received messages" testUpdatePeerChatVRange + describe "network statuses" $ do + it "should get network statuses" testGetNetworkStatuses where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -2623,6 +2625,20 @@ testUpdatePeerChatVRange tmp = where cfg11 = testCfg {chatVRange = vr11} :: ChatConfig +testGetNetworkStatuses :: HasCallStack => FilePath -> IO () +testGetNetworkStatuses tmp = do + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + alice ##> "/_network_statuses" + alice <## "1 connections connected" + withTestChatCfg tmp cfg "alice" $ \alice -> + withTestChatCfg tmp cfg "bob" $ \bob -> do + alice <## "1 connections connected" + bob <## "1 connections connected" + where + cfg = testCfg {coreApi = True} + vr11 :: VersionRange vr11 = mkVersionRange 1 1 diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 692dd5884e..aa36b397a3 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -120,19 +120,19 @@ chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" chatStartedTagged :: LB.ByteString chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" -contactSubSummary :: LB.ByteString -contactSubSummary = +networkStatuses :: LB.ByteString +networkStatuses = #if defined(darwin_HOST_OS) && defined(swiftJSON) - contactSubSummarySwift + networkStatusesSwift #else - contactSubSummaryTagged + networkStatusesTagged #endif -contactSubSummarySwift :: LB.ByteString -contactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" +networkStatusesSwift :: LB.ByteString +networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" -contactSubSummaryTagged :: LB.ByteString -contactSubSummaryTagged = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" +networkStatusesTagged :: LB.ByteString +networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" memberSubSummary :: LB.ByteString memberSubSummary = @@ -143,10 +143,10 @@ memberSubSummary = #endif memberSubSummarySwift :: LB.ByteString -memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" +memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" memberSubSummaryTagged :: LB.ByteString -memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" +memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" userContactSubSummary :: LB.ByteString userContactSubSummary = @@ -157,10 +157,10 @@ userContactSubSummary = #endif userContactSubSummarySwift :: LB.ByteString -userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" +userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" userContactSubSummaryTagged :: LB.ByteString -userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" +userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" pendingSubSummary :: LB.ByteString pendingSubSummary = @@ -171,13 +171,13 @@ pendingSubSummary = #endif pendingSubSummarySwift :: LB.ByteString -pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" +pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" pendingSubSummaryTagged :: LB.ByteString -pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" +pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" userJSON :: LB.ByteString -userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" parsedMarkdown :: LB.ByteString parsedMarkdown = @@ -215,7 +215,7 @@ testChatApi tmp = do chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted - chatRecvMsg cc `shouldReturn` contactSubSummary + chatRecvMsg cc `shouldReturn` networkStatuses chatRecvMsg cc `shouldReturn` userContactSubSummary chatRecvMsg cc `shouldReturn` memberSubSummary chatRecvMsgWait cc 10000 `shouldReturn` pendingSubSummary From 07047a3ef365bf779bb2d74281e37e45a2cdf57d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 14:29:48 +0100 Subject: [PATCH 071/294] ios: fix pattern match --- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e4f64ee977..18e7f7efbb 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1374,7 +1374,7 @@ func processReceivedMsg(_ res: ChatResponse) async { m.networkStatuses[cId] = status } } - case let .networkStatuses(statuses): () + case let .networkStatuses(_, statuses): () await MainActor.run { for s in statuses { m.networkStatuses[s.agentConnId] = s.networkStatus From c609303348784a23a134424faaad1e2813b247ab Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 13 Oct 2023 19:19:00 +0400 Subject: [PATCH 072/294] ios: connect plan (#3205) * ios: connect plan * improvements * wording * fixes * rework to use dismissAllSheets with callback * rework * update texts * Update apps/ios/Shared/Views/NewChat/NewChatButton.swift * Update apps/ios/Shared/Views/NewChat/NewChatButton.swift --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/ContentView.swift | 37 +- apps/ios/Shared/Model/ChatModel.swift | 10 + apps/ios/Shared/Model/SimpleXAPI.swift | 21 +- .../Chat/Group/GroupMemberInfoView.swift | 35 +- .../Shared/Views/NewChat/NewChatButton.swift | 346 ++++++++++++++++-- .../Views/NewChat/PasteToConnectView.swift | 18 +- .../Views/NewChat/ScanToConnectView.swift | 18 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 30 +- apps/ios/SimpleXChat/APITypes.swift | 34 ++ 9 files changed, 437 insertions(+), 112 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 3dbcf47004..04eadac2c1 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -31,11 +31,11 @@ struct ContentView: View { @State private var chatListActionSheet: ChatListActionSheet? = nil private enum ChatListActionSheet: Identifiable { - case connectViaUrl(action: ConnReqType, link: String) + case planAndConnectSheet(sheet: PlanAndConnectActionSheet) var id: String { switch self { - case let .connectViaUrl(_, link): return "connectViaUrl \(link)" + case let .planAndConnectSheet(sheet): return sheet.id } } } @@ -93,7 +93,7 @@ struct ContentView: View { mainView() .actionSheet(item: $chatListActionSheet) { sheet in switch sheet { - case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link) + case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false) } } } else { @@ -290,12 +290,19 @@ struct ContentView: View { if let url = m.appOpenUrl { m.appOpenUrl = nil var path = url.path - logger.debug("ContentView.connectViaUrl path: \(path)") if (path == "/contact" || path == "/invitation") { path.removeFirst() - let action: ConnReqType = path == "contact" ? .contact : .invitation - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - chatListActionSheet = .connectViaUrl(action: action, link: link) + // TODO normalize in backend; revert + // let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + var link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + link = link.starts(with: "simplex:/") ? link.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") : link + planAndConnect( + link, + showAlert: showPlanAndConnectAlert, + showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) }, + dismiss: false, + incognito: nil + ) } else { AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) } @@ -303,20 +310,8 @@ struct ContentView: View { } } - private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet { - let title: LocalizedStringKey - switch action { - case .contact: title = "Connect via contact link" - case .invitation: title = "Connect via one-time link" - } - return ActionSheet( - title: Text(title), - buttons: [ - .default(Text("Use current profile")) { connectViaLink(link, incognito: false) }, - .default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) }, - .cancel() - ] - ) + private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) { + AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false)) } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index ec1a567420..b1a83dd5e6 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -152,6 +152,16 @@ final class ChatModel: ObservableObject { } } + func getGroupChat(_ groupId: Int64) -> Chat? { + chats.first { chat in + if case let .group(groupInfo) = chat.chatInfo { + return groupInfo.groupId == groupId + } else { + return false + } + } + } + private func getChatIndex(_ id: String) -> Int? { chats.firstIndex(where: { $0.id == id }) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 18e7f7efbb..a1c8cee774 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -586,6 +586,15 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P throw r } +func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { + logger.error("apiConnectPlan connReq: \(connReq)") + let userId = try currentUserId("apiConnectPlan") + let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) + if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan } + logger.error("apiConnectPlan error: \(responseError(r))") + throw r +} + func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) if let alert = alert { @@ -610,10 +619,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert if let c = m.getContactChat(contact.contactId) { await MainActor.run { m.chatId = c.id } } - let alert = mkAlert( - title: "Contact already exists", - message: "You are already connected to \(contact.displayName)." - ) + let alert = contactAlreadyExistsAlert(contact) return (nil, alert) case .chatCmdError(_, .error(.invalidConnReq)): let alert = mkAlert( @@ -641,6 +647,13 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert return (nil, alert) } +func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { + mkAlert( + title: "Contact already exists", + message: "You are already connected to \(contact.displayName)." + ) +} + private func connectionErrorAlert(_ r: ChatResponse) -> Alert { if let networkErrorAlert = networkErrorAlert(r) { return networkErrorAlert diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 5ec14f5be1..3d10101dee 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -19,7 +19,7 @@ struct GroupMemberInfoView: View { @State private var connectionCode: String? = nil @State private var newRole: GroupMemberRole = .member @State private var alert: GroupMemberInfoViewAlert? - @State private var connectToMemberDialog: Bool = false + @State private var sheet: PlanAndConnectActionSheet? @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var justOpened = true @State private var progressIndicator = false @@ -30,9 +30,8 @@ struct GroupMemberInfoView: View { case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert - case connRequestSentAlert(type: ConnReqType) + case planAndConnectAlert(alert: PlanAndConnectAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey) - case other(alert: Alert) var id: String { switch self { @@ -41,9 +40,8 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" - case .connRequestSentAlert: return "connRequestSentAlert" + case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" case let .error(title, _): return "error \(title)" - case let .other(alert): return "other \(alert)" } } } @@ -206,11 +204,11 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) - case let .connRequestSentAlert(type): return connReqSentAlert(type) + case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) - case let .other(alert): return alert } } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } if progressIndicator { ProgressView().scaleEffect(2) @@ -220,25 +218,16 @@ struct GroupMemberInfoView: View { func connectViaAddressButton(_ contactLink: String) -> some View { Button { - connectToMemberDialog = true + planAndConnect( + contactLink, + showAlert: { alert = .planAndConnectAlert(alert: $0) }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: nil + ) } label: { Label("Connect", systemImage: "link") } - .confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) { - Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) } - Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) } - } - } - - func connectViaAddress(incognito: Bool, contactLink: String) { - Task { - let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink) - if let connReqType = connReqType { - alert = .connRequestSentAlert(type: connReqType) - } else if let connectAlert = connectAlert { - alert = .other(alert: connectAlert) - } - } } func knownDirectChatButton(_ chat: Chat) -> some View { diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index a727ad6be0..e4fbd8bd47 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -58,65 +58,331 @@ struct NewChatButton: View { } } -enum ConnReqType: Equatable { - case contact - case invitation +enum PlanAndConnectAlert: Identifiable { + case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case invitationLinkConnecting(connectionLink: String) + case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?) + + var id: String { + switch self { + case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)" + case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)" + case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)" + case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" + case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" + } + } } -func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) { - Task { - if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { - DispatchQueue.main.async { - dismiss?() - AlertManager.shared.showAlert(connReqSentAlert(connReqType)) - } +func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { + switch alert { + case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Connect to yourself?"), + message: Text("This is your own one-time link!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case .invitationLinkConnecting: + return Alert( + title: Text("Already connecting!"), + message: Text("You are already connecting via this one-time link!") + ) + case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Connect to yourself?"), + message: Text("This is your own SimpleX address!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Join group?"), + message: Text("You will connect to all group members."), + primaryButton: .default( + Text(incognito ? "Join incognito" : "Join"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .groupLinkConnecting(_, groupInfo): + if let groupInfo = groupInfo { + return Alert( + title: Text("Group already exists!"), + message: Text("You are already joining the group \(groupInfo.displayName).") + ) } else { - DispatchQueue.main.async { - dismiss?() + return Alert( + title: Text("Already joining the group!"), + message: Text("You are already joining the group via this link.") + ) + } + } +} + +enum PlanAndConnectActionSheet: Identifiable { + case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) + case ownLinkAskCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) + case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) + + var id: String { + switch self { + case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" + case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, _, _): return "ownLinkAskCurrentOrIncognitoProfile \(connectionLink)" + case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" + } + } +} + +func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet { + switch sheet { + case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title): + return ActionSheet( + title: Text(title), + buttons: [ + .default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, connectionPlan, title): + return ActionSheet( + title: Text(title), + buttons: [ + .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo): + if let incognito = incognito { + return ActionSheet( + title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"), + buttons: [ + .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) }, + .destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"), + buttons: [ + .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) }, + .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + } + } +} + +func planAndConnect( + _ connectionLink: String, + showAlert: @escaping (PlanAndConnectAlert) -> Void, + showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void, + dismiss: Bool, + incognito: Bool? +) { + Task { + do { + let connectionPlan = try await apiConnectPlan(connReq: connectionLink) + switch connectionPlan { + case let .invitationLink(ilp): + switch ilp { + case .ok: + logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link")) + } + case .ownLink: + logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + } + case let .connecting(contact_): + logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")") + if let contact = contact_ { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + } else { + showAlert(.invitationLinkConnecting(connectionLink: connectionLink)) + } + case let .known(contact): + logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } + case let .contactAddress(cap): + switch cap { + case .ok: + logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address")) + } + case .ownLink: + logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + } + case let .connecting(contact): + logger.debug("planAndConnect, .contactAddress, .connecting, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + case let .known(contact): + logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } + case let .groupLink(glp): + switch glp { + case .ok: + if let incognito = incognito { + showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group")) + } + case let .ownLink(groupInfo): + logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")") + showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) + case let .connecting(groupInfo_): + logger.debug("planAndConnect, .groupLink, .connecting, incognito=\(incognito?.description ?? "nil")") + showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) + case let .known(groupInfo): + logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") + openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) } + } + } + } catch { + logger.debug("planAndConnect, plan error") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) } } } } -struct CReqClientData: Decodable { - var type: String - var groupLinkId: String? -} - -func parseLinkQueryData(_ connectionLink: String) -> CReqClientData? { - if let hashIndex = connectionLink.firstIndex(of: "#"), - let urlQuery = URL(string: String(connectionLink[connectionLink.index(after: hashIndex)...])), - let components = URLComponents(url: urlQuery, resolvingAgainstBaseURL: false), - let data = components.queryItems?.first(where: { $0.name == "data" })?.value, - let d = data.data(using: .utf8), - let crData = try? getJSONDecoder().decode(CReqClientData.self, from: d) { - return crData - } else { - return nil +private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) { + Task { + if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { + let crt: ConnReqType + if let plan = connectionPlan { + crt = planToConnReqType(plan) + } else { + crt = connReqType + } + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + AlertManager.shared.showAlert(connReqSentAlert(crt)) + } + } else { + AlertManager.shared.showAlert(connReqSentAlert(crt)) + } + } + } else { + if dismiss { + DispatchQueue.main.async { + dismissAllSheets(animated: true) + } + } + } } } -func checkCRDataGroup(_ crData: CReqClientData) -> Bool { - return crData.type == "group" && crData.groupLinkId != nil +func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { + Task { + let m = ChatModel.shared + if let c = m.getContactChat(contact.contactId) { + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + m.chatId = c.id + showAlreadyExistsAlert?() + } + } else { + m.chatId = c.id + showAlreadyExistsAlert?() + } + } + } + } } -func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert { - return Alert( - title: Text("Connect via group link?"), - message: Text("You will join a group this link refers to and connect to its group members."), - primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) { - connectViaLink(connectionLink, incognito: incognito) - }, - secondaryButton: .cancel() +func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { + Task { + let m = ChatModel.shared + if let g = m.getGroupChat(groupInfo.groupId) { + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + m.chatId = g.id + showAlreadyExistsAlert?() + } + } else { + m.chatId = g.id + showAlreadyExistsAlert?() + } + } + } + } +} + +func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert { + mkAlert( + title: "Contact already exists", + message: "You are already connecting to \(contact.displayName)." ) } +func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert { + mkAlert( + title: "Group already exists", + message: "You are already in group \(groupInfo.displayName)." + ) +} + +enum ConnReqType: Equatable { + case invitation + case contact + case groupLink + + var connReqSentText: LocalizedStringKey { + switch self { + case .invitation: return "You will be connected when your contact's device is online, please wait or check later!" + case .contact: return "You will be connected when your connection request is accepted, please wait or check later!" + case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!" + } + } +} + +private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType { + switch connectionPlan { + case .invitationLink: return .invitation + case .contactAddress: return .contact + case .groupLink: return .groupLink + } +} + func connReqSentAlert(_ type: ConnReqType) -> Alert { return mkAlert( title: "Connection request sent!", - message: type == .contact - ? "You will be connected when your connection request is accepted, please wait or check later!" - : "You will be connected when your contact's device is online, please wait or check later!" + message: type.connReqSentText ) } diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index ec199327ce..af84f19fec 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -14,6 +14,8 @@ struct PasteToConnectView: View { @State private var connectionLink: String = "" @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @FocusState private var linkEditorFocused: Bool + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? var body: some View { List { @@ -57,6 +59,8 @@ struct PasteToConnectView: View { + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") } } + .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } } private func linkEditor() -> some View { @@ -83,13 +87,13 @@ struct PasteToConnectView: View { private func connect() { let link = connectionLink.trimmingCharacters(in: .whitespaces) - if let crData = parseLinkQueryData(link), - checkCRDataGroup(crData) { - dismiss() - AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault)) - } else { - connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault) - } + planAndConnect( + link, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: incognitoDefault + ) } } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 01c8a4659e..c55ba1502e 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -13,6 +13,8 @@ import CodeScanner struct ScanToConnectView: View { @Environment(\.dismiss) var dismiss: DismissAction @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? var body: some View { ScrollView { @@ -49,18 +51,20 @@ struct ScanToConnectView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } .background(Color(.systemGroupedBackground)) + .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } } func processQRCode(_ resp: Result) { switch resp { case let .success(r): - if let crData = parseLinkQueryData(r.string), - checkCRDataGroup(crData) { - dismiss() - AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault)) - } else { - Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) } - } + planAndConnect( + r.string, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: incognitoDefault + ) case let .failure(e): logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") dismiss() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 35d0b1de8a..2a90e75d85 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -176,6 +176,11 @@ 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */; }; + 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; }; + 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; }; + 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C812AD6B6B900B21C4C /* libffi.a */; }; + 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; @@ -458,6 +463,11 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; + 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; }; + 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; }; + 64AB9C812AD6B6B900B21C4C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; @@ -507,13 +517,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */, - 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */, - 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, - 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, + 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, + 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */, + 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */, + 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +584,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CA8D0122AD746C8001FD661 /* libffi.a */, - 5CA8D0152AD746C8001FD661 /* libgmp.a */, - 5CA8D0112AD746C8001FD661 /* libgmpxx.a */, - 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, - 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, + 64AB9C812AD6B6B900B21C4C /* libffi.a */, + 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */, + 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */, + 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, + 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d29e2481d5..9eb9b9084c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -86,6 +86,7 @@ public enum ChatCommand { case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) case apiAddContact(userId: Int64, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool) + case apiConnectPlan(userId: Int64, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64) @@ -219,6 +220,7 @@ public enum ChatCommand { case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" + case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" @@ -335,6 +337,7 @@ public enum ChatCommand { case .apiVerifyGroupMember: return "apiVerifyGroupMember" case .apiAddContact: return "apiAddContact" case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" + case .apiConnectPlan: return "apiConnectPlan" case .apiConnect: return "apiConnect" case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" @@ -460,6 +463,7 @@ public enum ChatResponse: Decodable, Error { case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) + case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) case sentConfirmation(user: UserRef) case sentInvitation(user: UserRef) case contactAlreadyExists(user: UserRef, contact: Contact) @@ -601,6 +605,7 @@ public enum ChatResponse: Decodable, Error { case .connectionVerified: return "connectionVerified" case .invitation: return "invitation" case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" + case .connectionPlan: return "connectionPlan" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" case .contactAlreadyExists: return "contactAlreadyExists" @@ -739,6 +744,7 @@ public enum ChatResponse: Decodable, Error { case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) @@ -859,6 +865,33 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? { } } +public enum ConnectionPlan: Decodable { + case invitationLink(invitationLinkPlan: InvitationLinkPlan) + case contactAddress(contactAddressPlan: ContactAddressPlan) + case groupLink(groupLinkPlan: GroupLinkPlan) +} + +public enum InvitationLinkPlan: Decodable { + case ok + case ownLink + case connecting(contact_: Contact?) + case known(contact: Contact) +} + +public enum ContactAddressPlan: Decodable { + case ok + case ownLink + case connecting(contact: Contact) + case known(contact: Contact) +} + +public enum GroupLinkPlan: Decodable { + case ok + case ownLink(groupInfo: GroupInfo) + case connecting(groupInfo_: GroupInfo?) + case known(groupInfo: GroupInfo) +} + struct NewUser: Encodable { var profile: Profile? var sameServers: Bool @@ -1477,6 +1510,7 @@ public enum ChatErrorType: Decodable { case chatNotStarted case chatNotStopped case chatStoreChanged + case connectionPlan(connectionPlan: ConnectionPlan) case invalidConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact) From 392447ea331988f40725580c44b646384d506486 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 17:52:27 +0100 Subject: [PATCH 073/294] core: fix test --- src/Simplex/Chat/Remote.hs | 2 ++ tests/JSONTests.hs | 2 +- tests/RemoteTests.hs | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 96d15ee8ae..49357763d2 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -8,6 +8,8 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + module Simplex.Chat.Remote where import Control.Logger.Simple diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 11567d94af..a250cdfcf6 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -26,7 +26,7 @@ owsf2TaggedJSONTest = do activeUserExistsSwift `to` activeUserExistsTagged activeUserSwift `to` activeUserTagged chatStartedSwift `to` chatStartedTagged - contactSubSummarySwift `to` contactSubSummaryTagged + networkStatusesSwift `to` networkStatusesTagged memberSubSummarySwift `to` memberSubSummaryTagged userContactSubSummarySwift `to` userContactSubSummaryTagged pendingSubSummarySwift `to` pendingSubSummaryTagged diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 84f361a4a4..d2392adbbd 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -30,7 +30,6 @@ import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import System.FilePath (makeRelative, ()) import Test.Hspec import UnliftIO -import UnliftIO.Concurrent (threadDelay) import UnliftIO.Directory remoteTests :: SpecWith FilePath From 193361c09a236bc4a36ed275010f140d930d2bf5 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 13 Oct 2023 20:53:04 +0300 Subject: [PATCH 074/294] core: fix remote handshake test (#3209) * Fix remoteHandshakeTest Sidesteps some yet to be uncovered bug when mobile stops its side before the desktop. * remove ambiguous update warning --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat/Remote.hs | 22 ++++++++++++++++------ tests/RemoteTests.hs | 27 +++++++++++++++++++-------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 49357763d2..26d4f4bfd2 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -53,7 +54,7 @@ import Simplex.FileTransfer.Util (uniqueCombine) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) -import Simplex.Messaging.Parsers (pattern SingleFieldJSONTag, pattern TaggedObjectJSONTag, pattern TaggedObjectJSONData) +import Simplex.Messaging.Parsers (pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) @@ -64,7 +65,7 @@ import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 import Simplex.Messaging.Util (bshow, ifM, tshow) import System.FilePath (isPathSeparator, takeFileName, ()) import UnliftIO -import UnliftIO.Directory (createDirectoryIfMissing, getFileSize, makeAbsolute) +import UnliftIO.Directory (createDirectoryIfMissing, getFileSize) withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a withRemoteHostSession remoteHostId action = do @@ -90,7 +91,9 @@ startRemoteHost remoteHostId = do cleanup finished = do logInfo "Remote host http2 client fininshed" atomically $ writeTVar finished True - closeRemoteHostSession remoteHostId >>= toView + M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case + Nothing -> logInfo $ "Session already closed for remote host " <> tshow remoteHostId + Just _ -> closeRemoteHostSession remoteHostId >>= toView run RemoteHost {storePath, caKey, caCert} = do finished <- newTVarIO False let parent = (C.signatureKeyPair caKey, caCert) @@ -141,6 +144,7 @@ pollRemote finished http path action = loop closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m ChatResponse closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do + logInfo $ "Closing remote host session for " <> tshow remoteHostId liftIO $ cancelRemoteHostSession session chatWriteVar currentRemoteHost Nothing chatModifyVar remoteHostSessions $ M.delete remoteHostId @@ -174,8 +178,12 @@ listRemoteHosts = do pure RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse -deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \rh -> do - -- TODO: delete files +deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do + chatReadVar filesFolder >>= \case + Just baseDir -> do + let hostStore = baseDir storePath + logError $ "TODO: remove " <> tshow hostStore + Nothing -> logWarn "Local file store not available while deleting remote host" withStore' $ \db -> deleteRemoteHostRecord db remoteHostId pure CRRemoteHostDeleted {remoteHostId} @@ -234,7 +242,9 @@ handleRcvFileComplete http storePath remoteUser cif@CIFile {fileId, fileName, fi createDirectoryIfMissing True hostStore localPath <- uniqueCombine hostStore fileName ok <- fetchRemoteFile http remoteUser fileId localPath - pure $ Just (cif {fileName = localPath} :: CIFile 'MDRcv) + if ok + then pure $ Just (cif {fileName = localPath} :: CIFile 'MDRcv) + else Nothing <$ logError "fetchRemoteFile failed" Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" _ -> Nothing <$ logDebug ("Ingoring invalid file notification for file (" <> tshow fileId <> ") " <> tshow fileName) diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index d2392adbbd..479febbcaf 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -36,7 +36,7 @@ remoteTests :: SpecWith FilePath remoteTests = describe "Handshake" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test - xit "connects desktop and mobile" remoteHandshakeTest + it "connects desktop and mobile" remoteHandshakeTest it "send messages via remote desktop" remoteCommandTest -- * Low-level TLS with ephemeral credentials @@ -129,6 +129,24 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start mobile <## "remote controller 1 connecting to TODO" mobile <## "remote controller 1 connected, TODO" + + traceM " - Session active" + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <## "1. TODO (active)" + mobile ##> "/list remote ctrls" + mobile <## "Remote controllers:" + mobile <## "1. TODO (active)" + + traceM " - Shutting desktop" + desktop ##> "/stop remote host 1" + desktop <## "remote host 1 stopped" + desktop ##> "/delete remote host 1" + desktop <## "remote host 1 deleted" + desktop ##> "/list remote hosts" + desktop <## "No remote hosts" + + traceM " - Shutting mobile" mobile ##> "/stop remote ctrl" mobile <## "ok" mobile <## "remote controller stopped" @@ -137,13 +155,6 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile ##> "/list remote ctrls" mobile <## "No remote controllers" - desktop ##> "/stop remote host 1" - desktop <## "remote host 1 stopped" - desktop ##> "/delete remote host 1" - desktop <## "remote host 1 deleted" - desktop ##> "/list remote hosts" - desktop <## "No remote hosts" - remoteCommandTest :: (HasCallStack) => FilePath -> IO () remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do let mobileFiles = "./tests/tmp/mobile_files" From a35dc263b78fa6a30dfe2b2702f82ff03469e9d2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 22:28:55 +0100 Subject: [PATCH 075/294] website: remove address from connect page --- website/src/_includes/contact_page.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html index a03a3aea4f..6beb148f8d 100644 --- a/website/src/_includes/contact_page.html +++ b/website/src/_includes/contact_page.html @@ -152,7 +152,7 @@ v1.0.0+, {{ "copy-the-command-below-text" | i18n({}, lang ) | safe }}

- /c https://simplex.chat/contact#/?v=1&smp=smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im/KBCmxJ3-lEjpWLPPkI6OWPk-YJneU5uY%23MCowBQYDK2VuAyEAtixHJWDXvYWcoe-77vIfjvI6XWEuzUsapMS9nVHP_Go= +

From 5e6aaffb09b528f9d7af62c07a0f48cd8b3a9d73 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 22:35:30 +0100 Subject: [PATCH 076/294] simplify remote api, add ios api (#3213) --- apps/ios/Shared/Model/SimpleXAPI.swift | 32 +++++++ apps/ios/SimpleXChat/APITypes.swift | 72 ++++++++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 96 ++++++++----------- src/Simplex/Chat.hs | 24 ++--- src/Simplex/Chat/Controller.hs | 14 +-- src/Simplex/Chat/Remote.hs | 45 ++++----- src/Simplex/Chat/View.hs | 6 -- tests/RemoteTests.hs | 18 ++-- tests/Test.hs | 2 +- 9 files changed, 184 insertions(+), 125 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a1c8cee774..ad76364e96 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -882,6 +882,38 @@ func apiCancelFile(fileId: Int64) async -> AChatItem? { } } +func startRemoteCtrl() async throws { + try await sendCommandOkResp(.startRemoteCtrl) +} + +func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> Int64 { + let r = await chatSendCmd(.registerRemoteCtrl(remoteCtrlOOB: remoteCtrlOOB)) + if case let .remoteCtrlRegistered(rcId) = r { return rcId } + throw r +} + +func listRemoteCtrls() async throws -> [RemoteCtrlInfo] { + let r = await chatSendCmd(.listRemoteCtrls) + if case let .remoteCtrlList(rcInfo) = r { return rcInfo } + throw r +} + +func acceptRemoteCtrl(_ rcId: Int64) async throws { + try await sendCommandOkResp(.acceptRemoteCtrl(remoteCtrlId: rcId)) +} + +func rejectRemoteCtrl(_ rcId: Int64) async throws { + try await sendCommandOkResp(.rejectRemoteCtrl(remoteCtrlId: rcId)) +} + +func stopRemoteCtrl() async throws { + try await sendCommandOkResp(.stopRemoteCtrl) +} + +func deleteRemoteCtrl(_ rcId: Int64) async throws { + try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId)) +} + func networkErrorAlert(_ r: ChatResponse) -> Alert? { switch r { case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 9eb9b9084c..4b79800e1a 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -117,6 +117,13 @@ public enum ChatCommand { case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) case setFileToReceive(fileId: Int64, encrypted: Bool) case cancelFile(fileId: Int64) + case startRemoteCtrl + case registerRemoteCtrl(remoteCtrlOOB: RemoteCtrlOOB) + case listRemoteCtrls + case acceptRemoteCtrl(remoteCtrlId: Int64) + case rejectRemoteCtrl(remoteCtrlId: Int64) + case stopRemoteCtrl + case deleteRemoteCtrl(remoteCtrlId: Int64) case showVersion case string(String) @@ -255,6 +262,13 @@ public enum ChatCommand { return s case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))" case let .cancelFile(fileId): return "/fcancel \(fileId)" + case .startRemoteCtrl: return "/start remote ctrl" + case let .registerRemoteCtrl(oob): return "/register remote ctrl \(oob.caFingerprint)" + case let .acceptRemoteCtrl(rcId): return "/accept remote ctrl \(rcId)" + case let .rejectRemoteCtrl(rcId): return "/reject remote ctrl \(rcId)" + case .listRemoteCtrls: return "/list remote ctrls" + case .stopRemoteCtrl: return "/stop remote ctrl" + case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" case .showVersion: return "/version" case let .string(str): return str } @@ -367,6 +381,13 @@ public enum ChatCommand { case .receiveFile: return "receiveFile" case .setFileToReceive: return "setFileToReceive" case .cancelFile: return "cancelFile" + case .startRemoteCtrl: return "startRemoteCtrl" + case .registerRemoteCtrl: return "registerRemoteCtrl" + case .listRemoteCtrls: return "listRemoteCtrls" + case .acceptRemoteCtrl: return "acceptRemoteCtrl" + case .rejectRemoteCtrl: return "rejectRemoteCtrl" + case .stopRemoteCtrl: return "stopRemoteCtrl" + case .deleteRemoteCtrl: return "deleteRemoteCtrl" case .showVersion: return "showVersion" case .string: return "console command" } @@ -563,6 +584,13 @@ public enum ChatResponse: Decodable, Error { case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case newContactConnection(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) + case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) + case remoteCtrlRegistered(remoteCtrlId: Int64) + case remoteCtrlAnnounce(fingerprint: String) + case remoteCtrlFound(remoteCtrl: RemoteCtrl) + case remoteCtrlConnecting(remoteCtrlId: Int64, displayName: String) + case remoteCtrlConnected(remoteCtrlId: Int64, displayName: String) + case remoteCtrlStopped case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) case chatCmdError(user_: UserRef?, chatError: ChatError) @@ -699,6 +727,13 @@ public enum ChatResponse: Decodable, Error { case .ntfMessages: return "ntfMessages" case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" + case .remoteCtrlList: return "remoteCtrlList" + case .remoteCtrlRegistered: return "remoteCtrlRegistered" + case .remoteCtrlAnnounce: return "remoteCtrlAnnounce" + case .remoteCtrlFound: return "remoteCtrlFound" + case .remoteCtrlConnecting: return "remoteCtrlConnecting" + case .remoteCtrlConnected: return "remoteCtrlConnected" + case .remoteCtrlStopped: return "remoteCtrlStopped" case .versionInfo: return "versionInfo" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" @@ -838,6 +873,13 @@ public enum ChatResponse: Decodable, Error { case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) + case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) + case let .remoteCtrlRegistered(rcId): return "remote ctrl ID: \(rcId)" + case let .remoteCtrlAnnounce(fingerprint): return "fingerprint: \(fingerprint)" + case let .remoteCtrlFound(remoteCtrl): return "remote ctrl: \(String(describing: remoteCtrl))" + case let .remoteCtrlConnecting(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)" + case let .remoteCtrlConnected(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)" + case .remoteCtrlStopped: return noDetails case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" case .cmdOk: return noDetails case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) @@ -1461,6 +1503,23 @@ public enum NotificationPreviewMode: String, SelectableItem { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } +public struct RemoteCtrlOOB { + public var caFingerprint: String +} + +public struct RemoteCtrlInfo: Decodable { + public var remoteCtrlId: Int64 + public var displayName: String + public var sessionActive: Bool +} + +public struct RemoteCtrl: Decodable { + var remoteCtrlId: Int64 + var displayName: String + var fingerprint: String + var accepted: Bool? +} + public struct CoreVersionInfo: Decodable { public var version: String public var simplexmqVersion: String @@ -1488,6 +1547,7 @@ public enum ChatError: Decodable { case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) case errorDatabase(databaseError: DatabaseError) + case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) case invalidJSON(json: String) } @@ -1739,3 +1799,15 @@ public enum ArchiveError: Decodable { case `import`(chatError: ChatError) case importFile(file: String, chatError: ChatError) } + +public enum RemoteCtrlError: Decodable { + case missing(remoteCtrlId: Int64) + case inactive + case busy + case timeout + case disconnected(remoteCtrlId: Int64, reason: String) + case connectionLost(remoteCtrlId: Int64, reason: String) + case certificateExpired(remoteCtrlId: Int64) + case certificateUntrusted(remoteCtrlId: Int64) + case badFingerprint +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 9d726c620d..9ab6060fe9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1938,8 +1938,8 @@ sealed class CC { class StartRemoteHost(val remoteHostId: Long): CC() class StopRemoteHost(val remoteHostId: Long): CC() class DeleteRemoteHost(val remoteHostId: Long): CC() - class RegisterRemoteCtrl(val remoteCtrlOOB: RemoteCtrlOOB): CC() class StartRemoteCtrl(): CC() + class RegisterRemoteCtrl(val remoteCtrlOOB: RemoteCtrlOOB): CC() class ListRemoteCtrls(): CC() class AcceptRemoteCtrl(val remoteCtrlId: Long): CC() class RejectRemoteCtrl(val remoteCtrlId: Long): CC() @@ -2167,8 +2167,8 @@ sealed class CC { is StartRemoteHost -> "startRemoteHost" is StopRemoteHost -> "stopRemoteHost" is DeleteRemoteHost -> "deleteRemoteHost" - is RegisterRemoteCtrl -> "registerRemoteCtrl" is StartRemoteCtrl -> "startRemoteCtrl" + is RegisterRemoteCtrl -> "registerRemoteCtrl" is ListRemoteCtrls -> "listRemoteCtrls" is AcceptRemoteCtrl -> "acceptRemoteCtrl" is RejectRemoteCtrl -> "rejectRemoteCtrl" @@ -3483,30 +3483,24 @@ sealed class CR { @Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR() + // remote events (desktop) + @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHostId: Long, val oobData: RemoteCtrlOOB): CR() + @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() + @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() + // remote events (mobile) + @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() + @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() + @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrl): CR() + @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List): CR() - // remote events (desktop) - @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHostId: Long, val oobData: RemoteCtrlOOB): CR() - @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() - @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHostId: Long): CR() - @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHostId: Long): CR() - @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() - @Serializable @SerialName("remoteHostDeleted") class RemoteHostDeleted(val remoteHostId: Long): CR() - // remote events (mobile) - @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() - @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrlId: Long): CR() - @Serializable @SerialName("remoteCtrlStarted") class RemoteCtrlStarted(): CR() - @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() - @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrl): CR() - @Serializable @SerialName("remoteCtrlAccepted") class RemoteCtrlAccepted(val remoteCtrlId: Long): CR() - @Serializable @SerialName("remoteCtrlRejected") class RemoteCtrlRejected(val remoteCtrlId: Long): CR() - @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrlId: Long, val displayName: String): CR() - @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrlId: Long, val displayName: String): CR() - @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() - @Serializable @SerialName("remoteCtrlDeleted") class RemoteCtrlDeleted(val remoteCtrlId: Long): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -3632,28 +3626,22 @@ sealed class CR { is CallEnded -> "callEnded" is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" + is RemoteHostCreated -> "remoteHostCreated" + is RemoteHostList -> "remoteHostList" + is RemoteHostConnected -> "remoteHostConnected" + is RemoteHostStopped -> "remoteHostStopped" + is RemoteCtrlList -> "remoteCtrlList" + is RemoteCtrlRegistered -> "remoteCtrlRegistered" + is RemoteCtrlAnnounce -> "remoteCtrlAnnounce" + is RemoteCtrlFound -> "remoteCtrlFound" + is RemoteCtrlConnecting -> "remoteCtrlConnecting" + is RemoteCtrlConnected -> "remoteCtrlConnected" + is RemoteCtrlStopped -> "remoteCtrlStopped" is VersionInfo -> "versionInfo" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" is ArchiveImported -> "archiveImported" - is RemoteHostCreated -> "remoteHostCreated" - is RemoteHostList -> "remoteHostList" - is RemoteHostStarted -> "remoteHostStarted" - is RemoteHostConnected -> "remoteHostConnected" - is RemoteHostStopped -> "remoteHostStopped" - is RemoteHostDeleted -> "remoteHostDeleted" - is RemoteCtrlList -> "remoteCtrlList" - is RemoteCtrlRegistered -> "remoteCtrlRegistered" - is RemoteCtrlStarted -> "remoteCtrlStarted" - is RemoteCtrlAnnounce -> "remoteCtrlAnnounce" - is RemoteCtrlFound -> "remoteCtrlFound" - is RemoteCtrlAccepted -> "remoteCtrlAccepted" - is RemoteCtrlRejected -> "remoteCtrlRejected" - is RemoteCtrlConnecting -> "remoteCtrlConnecting" - is RemoteCtrlConnected -> "remoteCtrlConnected" - is RemoteCtrlStopped -> "remoteCtrlStopped" - is RemoteCtrlDeleted -> "remoteCtrlDeleted" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -3779,6 +3767,17 @@ sealed class CR { is CallEnded -> withUser(user, "contact: ${contact.id}") is NewContactConnection -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) + is RemoteHostCreated -> "remote host ID: $remoteHostId\noobData ${json.encodeToString(oobData)}" + is RemoteHostList -> "remote hosts: ${json.encodeToString(remoteHosts)}" + is RemoteHostConnected -> "remote host ID: $remoteHostId" + is RemoteHostStopped -> "remote host ID: $remoteHostId" + is RemoteCtrlList -> json.encodeToString(remoteCtrls) + is RemoteCtrlRegistered -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" + is RemoteCtrlFound -> "remote ctrl: ${json.encodeToString(remoteCtrl)}" + is RemoteCtrlConnecting -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" + is RemoteCtrlConnected -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" + is RemoteCtrlStopped -> "" is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" + "chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" + "agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}" @@ -3786,23 +3785,6 @@ sealed class CR { is ChatCmdError -> withUser(user_, chatError.string) is ChatRespError -> withUser(user_, chatError.string) is ArchiveImported -> "${archiveErrors.map { it.string } }" - is RemoteHostCreated -> "remote host ID: $remoteHostId\noobData ${json.encodeToString(oobData)}" - is RemoteHostList -> "remote hosts: ${json.encodeToString(remoteHosts)}" - is RemoteHostStarted -> "remote host $remoteHostId" - is RemoteHostConnected -> "remote host ID: $remoteHostId" - is RemoteHostStopped -> "remote host ID: $remoteHostId" - is RemoteHostDeleted -> "remote host ID: $remoteHostId" - is RemoteCtrlList -> json.encodeToString(remoteCtrls) - is RemoteCtrlRegistered -> "remote ctrl ID: $remoteCtrlId" - is RemoteCtrlStarted -> "" - is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" - is RemoteCtrlFound -> "remote ctrl: ${json.encodeToString(remoteCtrl)}" - is RemoteCtrlAccepted -> "remote ctrl ID: $remoteCtrlId" - is RemoteCtrlRejected -> "remote ctrl ID: $remoteCtrlId" - is RemoteCtrlConnecting -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" - is RemoteCtrlConnected -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" - is RemoteCtrlStopped -> "" - is RemoteCtrlDeleted -> "remote ctrl ID: $remoteCtrlId" is Response -> json is Invalid -> str } @@ -3948,16 +3930,16 @@ sealed class ChatError { is ChatErrorAgent -> "agent ${agentError.string}" is ChatErrorStore -> "store ${storeError.string}" is ChatErrorDatabase -> "database ${databaseError.string}" - is ChatErrorRemoteCtrl -> "remoteCtrl ${remoteCtrlError.string}" is ChatErrorRemoteHost -> "remoteHost ${remoteHostError.string}" + is ChatErrorRemoteCtrl -> "remoteCtrl ${remoteCtrlError.string}" is ChatErrorInvalidJSON -> "invalid json ${json}" } @Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError() @Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError() @Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError() @Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError() - @Serializable @SerialName("errorRemoteCtrl") class ChatErrorRemoteCtrl(val remoteCtrlError: RemoteCtrlError): ChatError() @Serializable @SerialName("errorRemoteHost") class ChatErrorRemoteHost(val remoteHostError: RemoteHostError): ChatError() + @Serializable @SerialName("errorRemoteCtrl") class ChatErrorRemoteCtrl(val remoteCtrlError: RemoteCtrlError): ChatError() @Serializable @SerialName("invalidJSON") class ChatErrorInvalidJSON(val json: String): ChatError() } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3e91bd621c..454e87ef55 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1891,18 +1891,18 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} - CreateRemoteHost -> createRemoteHost - ListRemoteHosts -> listRemoteHosts - StartRemoteHost rh -> startRemoteHost rh - StopRemoteHost rh -> closeRemoteHostSession rh - DeleteRemoteHost rh -> deleteRemoteHost rh - StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) - AcceptRemoteCtrl rc -> acceptRemoteCtrl rc - RejectRemoteCtrl rc -> rejectRemoteCtrl rc - StopRemoteCtrl -> stopRemoteCtrl - RegisterRemoteCtrl oob -> registerRemoteCtrl oob - ListRemoteCtrls -> listRemoteCtrls - DeleteRemoteCtrl rc -> deleteRemoteCtrl rc + CreateRemoteHost -> uncurry CRRemoteHostCreated <$> createRemoteHost + ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts + StartRemoteHost rh -> startRemoteHost rh >> ok_ + StopRemoteHost rh -> closeRemoteHostSession rh >> ok_ + DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ + StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_ + AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_ + RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_ + StopRemoteCtrl -> stopRemoteCtrl >> ok_ + RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> registerRemoteCtrl oob + ListRemoteCtrls -> CRRemoteCtrlList <$> listRemoteCtrls + DeleteRemoteCtrl rc -> deleteRemoteCtrl rc >> ok_ QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e4085ca791..5448f49603 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -425,8 +425,8 @@ data ChatCommand -- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host | StopRemoteHost RemoteHostId -- ^ Shut down a running session | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data - | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers + | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake | ListRemoteCtrls | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data @@ -631,21 +631,15 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteCtrlOOB} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} -- XXX: RemoteHostInfo is mostly concerned with session setup - | CRRemoteHostStarted {remoteHostId :: RemoteHostId} | CRRemoteHostConnected {remoteHostId :: RemoteHostId} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} - | CRRemoteHostDeleted {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlStarted | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrl} -- registered fingerprint, may connect - | CRRemoteCtrlAccepted {remoteCtrlId :: RemoteCtrlId} - | CRRemoteCtrlRejected {remoteCtrlId :: RemoteCtrlId} | CRRemoteCtrlConnecting {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} | CRRemoteCtrlStopped - | CRRemoteCtrlDeleted {remoteCtrlId :: RemoteCtrlId} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -667,21 +661,15 @@ allowRemoteEvent :: ChatResponse -> Bool allowRemoteEvent = \case CRRemoteHostCreated {} -> False CRRemoteHostList {} -> False - CRRemoteHostStarted {} -> False CRRemoteHostConnected {} -> False CRRemoteHostStopped {} -> False - CRRemoteHostDeleted {} -> False CRRemoteCtrlList {} -> False CRRemoteCtrlRegistered {} -> False - CRRemoteCtrlStarted {} -> False CRRemoteCtrlAnnounce {} -> False CRRemoteCtrlFound {} -> False - CRRemoteCtrlAccepted {} -> False - CRRemoteCtrlRejected {} -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlConnected {} -> False CRRemoteCtrlStopped {} -> False - CRRemoteCtrlDeleted {} -> False _ -> True logResponseToFile :: ChatResponse -> Bool diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 26d4f4bfd2..37283511fc 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -79,21 +79,20 @@ withRemoteHost remoteHostId action = Nothing -> throwError $ ChatErrorRemoteHost remoteHostId RHMissing Just rh -> action rh -startRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse +startRemoteHost :: (ChatMonad m) => RemoteHostId -> m () startRemoteHost remoteHostId = do asks remoteHostSessions >>= atomically . TM.lookup remoteHostId >>= \case Just _ -> throwError $ ChatErrorRemoteHost remoteHostId RHBusy Nothing -> withRemoteHost remoteHostId $ \rh -> do announcer <- async $ run rh chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarting {announcer} - pure CRRemoteHostStarted {remoteHostId} where cleanup finished = do logInfo "Remote host http2 client fininshed" atomically $ writeTVar finished True M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case Nothing -> logInfo $ "Session already closed for remote host " <> tshow remoteHostId - Just _ -> closeRemoteHostSession remoteHostId >>= toView + Just _ -> closeRemoteHostSession remoteHostId >> toView (CRRemoteHostStopped remoteHostId) run RemoteHost {storePath, caKey, caCert} = do finished <- newTVarIO False let parent = (C.signatureKeyPair caKey, caCert) @@ -142,42 +141,41 @@ pollRemote finished http path action = loop readTVarIO finished >>= (`unless` loop) req = HTTP2Client.requestNoBody "GET" path mempty -closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m ChatResponse +closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do logInfo $ "Closing remote host session for " <> tshow remoteHostId liftIO $ cancelRemoteHostSession session chatWriteVar currentRemoteHost Nothing chatModifyVar remoteHostSessions $ M.delete remoteHostId - pure CRRemoteHostStopped {remoteHostId} cancelRemoteHostSession :: (MonadUnliftIO m) => RemoteHostSession -> m () cancelRemoteHostSession = \case RemoteHostSessionStarting {announcer} -> cancel announcer RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient -createRemoteHost :: (ChatMonad m) => m ChatResponse +createRemoteHost :: (ChatMonad m) => m (RemoteHostId, RemoteCtrlOOB) createRemoteHost = do let displayName = "TODO" -- you don't have remote host name here, it will be passed from remote host ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) displayName storePath <- liftIO randomStorePath remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath displayName caKey caCert let oobData = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert} - pure CRRemoteHostCreated {remoteHostId, oobData} + pure (remoteHostId, oobData) -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 -listRemoteHosts :: (ChatMonad m) => m ChatResponse +listRemoteHosts :: (ChatMonad m) => m [RemoteHostInfo] listRemoteHosts = do stored <- withStore' getRemoteHosts active <- chatReadVar remoteHostSessions - pure $ CRRemoteHostList $ do + pure $ do RemoteHost {remoteHostId, storePath, displayName} <- stored let sessionActive = M.member remoteHostId active pure RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} -deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse +deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m () deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do chatReadVar filesFolder >>= \case Just baseDir -> do @@ -185,7 +183,6 @@ deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {store logError $ "TODO: remove " <> tshow hostStore Nothing -> logWarn "Local file store not available while deleting remote host" withStore' $ \db -> deleteRemoteHostRecord db remoteHostId - pure CRRemoteHostDeleted {remoteHostId} processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse processRemoteCommand RemoteHostSessionStarting {} _ = pure . CRChatError Nothing . ChatError $ CEInternalError "sending remote commands before session started" @@ -393,7 +390,7 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s -- * ChatRequest handlers -startRemoteCtrl :: (ChatMonad m) => (ByteString -> m ChatResponse) -> m ChatResponse +startRemoteCtrl :: (ChatMonad m) => (ByteString -> m ChatResponse) -> m () startRemoteCtrl execChatCommand = chatReadVar remoteCtrlSession >>= \case Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy @@ -416,7 +413,6 @@ startRemoteCtrl execChatCommand = chatWriteVar remoteCtrlSession Nothing toView CRRemoteCtrlStopped chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} - pure CRRemoteCtrlStarted discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () discoverRemoteCtrls discovered = Discovery.withListener go @@ -445,33 +441,32 @@ discoverRemoteCtrls discovered = Discovery.withListener go Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically _nonV4 -> go sock -registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m ChatResponse +registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m RemoteCtrlId registerRemoteCtrl RemoteCtrlOOB {caFingerprint} = do let displayName = "TODO" -- maybe include into OOB data remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint - pure $ CRRemoteCtrlRegistered {remoteCtrlId} + pure remoteCtrlId -listRemoteCtrls :: (ChatMonad m) => m ChatResponse +listRemoteCtrls :: (ChatMonad m) => m [RemoteCtrlInfo] listRemoteCtrls = do stored <- withStore' getRemoteCtrls active <- chatReadVar remoteCtrlSession >>= \case Nothing -> pure Nothing Just RemoteCtrlSession {accepted} -> atomically (tryReadTMVar accepted) - pure $ CRRemoteCtrlList $ do + pure $ do RemoteCtrl {remoteCtrlId, displayName} <- stored let sessionActive = active == Just remoteCtrlId pure RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} -acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () acceptRemoteCtrl remoteCtrlId = do withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId True chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive Just RemoteCtrlSession {accepted} -> atomically . void $ tryPutTMVar accepted remoteCtrlId -- the remote host can now proceed with connection - pure $ CRRemoteCtrlAccepted {remoteCtrlId} -rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () rejectRemoteCtrl remoteCtrlId = do withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId False chatReadVar remoteCtrlSession >>= \case @@ -479,9 +474,8 @@ rejectRemoteCtrl remoteCtrlId = do Just RemoteCtrlSession {discoverer, supervisor} -> do cancel discoverer cancel supervisor - pure $ CRRemoteCtrlRejected {remoteCtrlId} -stopRemoteCtrl :: (ChatMonad m) => m ChatResponse +stopRemoteCtrl :: (ChatMonad m) => m () stopRemoteCtrl = chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive @@ -489,7 +483,6 @@ stopRemoteCtrl = cancelRemoteCtrlSession rcs $ do chatWriteVar remoteCtrlSession Nothing toView CRRemoteCtrlStopped - pure $ CRCmdOk Nothing cancelRemoteCtrlSession_ :: (MonadUnliftIO m) => RemoteCtrlSession -> m () cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () @@ -503,12 +496,10 @@ cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} c cancel supervisor -- supervisor is blocked until session progresses cleanup -deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse +deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () deleteRemoteCtrl remoteCtrlId = chatReadVar remoteCtrlSession >>= \case - Nothing -> do - withStore' $ \db -> deleteRemoteCtrlRecord db remoteCtrlId - pure $ CRRemoteCtrlDeleted {remoteCtrlId} + Nothing -> withStore' $ \db -> deleteRemoteCtrlRecord db remoteCtrlId Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy withRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrl -> m a) -> m a diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1d474792a6..b5dce1ba84 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -264,21 +264,15 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRNtfMessages {} -> [] CRRemoteHostCreated rhId oobData -> ("remote host " <> sShow rhId <> " created") : viewRemoteCtrlOOBData oobData CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostStarted rhId -> ["remote host " <> sShow rhId <> " started"] CRRemoteHostConnected rhId -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] - CRRemoteHostDeleted rhId -> ["remote host " <> sShow rhId <> " deleted"] CRRemoteCtrlList cs -> viewRemoteCtrls cs CRRemoteCtrlRegistered rcId -> ["remote controller " <> sShow rcId <> " registered"] - CRRemoteCtrlStarted -> ["remote controller started"] CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlAccepted rcId -> ["remote controller " <> sShow rcId <> " accepted"] - CRRemoteCtrlRejected rcId -> ["remote controller " <> sShow rcId <> " rejected"] CRRemoteCtrlConnecting rcId rcName -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] CRRemoteCtrlConnected rcId rcName -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] CRRemoteCtrlStopped -> ["remote controller stopped"] - CRRemoteCtrlDeleted rcId -> ["remote controller " <> sShow rcId <> " deleted"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 479febbcaf..68ef6788e9 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -110,10 +110,10 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop <## "Remote hosts:" desktop <## "1. TODO" -- TODO host name probably should be Maybe, as when host is created there is no name yet desktop ##> "/start remote host 1" - desktop <## "remote host 1 started" + desktop <## "ok" mobile ##> "/start remote ctrl" - mobile <## "remote controller started" + mobile <## "ok" mobile <## "remote controller announced" mobile <## "connection code:" fingerprint' <- getTermLine mobile @@ -126,7 +126,7 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile <## "Remote controllers:" mobile <## "1. TODO" mobile ##> "/accept remote ctrl 1" - mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start + mobile <## "ok" -- alternative scenario: accepted before controller start mobile <## "remote controller 1 connecting to TODO" mobile <## "remote controller 1 connected, TODO" @@ -140,9 +140,9 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do traceM " - Shutting desktop" desktop ##> "/stop remote host 1" - desktop <## "remote host 1 stopped" + desktop <## "ok" desktop ##> "/delete remote host 1" - desktop <## "remote host 1 deleted" + desktop <## "ok" desktop ##> "/list remote hosts" desktop <## "No remote hosts" @@ -151,7 +151,7 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do mobile <## "ok" mobile <## "remote controller stopped" mobile ##> "/delete remote ctrl 1" - mobile <## "remote controller 1 deleted" + mobile <## "ok" mobile ##> "/list remote ctrls" mobile <## "No remote controllers" @@ -173,10 +173,10 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob fingerprint <- getTermLine desktop desktop ##> "/start remote host 1" - desktop <## "remote host 1 started" + desktop <## "ok" mobile ##> "/start remote ctrl" - mobile <## "remote controller started" + mobile <## "ok" mobile <## "remote controller announced" mobile <## "connection code:" fingerprint' <- getTermLine mobile @@ -184,7 +184,7 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob mobile ##> ("/register remote ctrl " <> fingerprint') mobile <## "remote controller 1 registered" mobile ##> "/accept remote ctrl 1" - mobile <## "remote controller 1 accepted" -- alternative scenario: accepted before controller start + mobile <## "ok" -- alternative scenario: accepted before controller start mobile <## "remote controller 1 connecting to TODO" mobile <## "remote controller 1 connected, TODO" desktop <## "remote host 1 connected" diff --git a/tests/Test.hs b/tests/Test.hs index 071ff3791e..6af51a0724 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -33,7 +33,7 @@ main = do describe "SimpleX chat client" chatTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests - describe "Remote session" remoteTests + fdescribe "Remote session" remoteTests where testBracket test = do t <- getSystemTime From f5e9bd4f8b60707f5f56df16c41a4782ab03d0f0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 14 Oct 2023 13:10:06 +0100 Subject: [PATCH 077/294] core: add set display name (#3216) * core: add set display name * enable all tests --- src/Simplex/Chat.hs | 12 +++++--- src/Simplex/Chat/Controller.hs | 12 +++++--- src/Simplex/Chat/Remote.hs | 50 ++++++++++++++++------------------ src/Simplex/Chat/View.hs | 16 +++++------ tests/RemoteTests.hs | 16 +++++------ tests/Test.hs | 2 +- 6 files changed, 57 insertions(+), 51 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 454e87ef55..726fbdce88 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -205,6 +205,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty + localDeviceName <- newTVarIO "" -- TODO set in config remoteHostSessions <- atomically TM.empty remoteCtrlSession <- newTVarIO Nothing filesFolder <- newTVarIO optFilesFolder @@ -236,6 +237,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles, rcvFiles, currentCalls, + localDeviceName, remoteHostSessions, remoteCtrlSession, config, @@ -1891,16 +1893,17 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} - CreateRemoteHost -> uncurry CRRemoteHostCreated <$> createRemoteHost + SetLocalDeviceName name -> withUser $ \_ -> chatWriteVar localDeviceName name >> ok_ + CreateRemoteHost -> CRRemoteHostCreated <$> createRemoteHost ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts StartRemoteHost rh -> startRemoteHost rh >> ok_ StopRemoteHost rh -> closeRemoteHostSession rh >> ok_ DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_ + RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> registerRemoteCtrl oob AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_ RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_ StopRemoteCtrl -> stopRemoteCtrl >> ok_ - RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> registerRemoteCtrl oob ListRemoteCtrls -> CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> deleteRemoteCtrl rc >> ok_ QuitChat -> liftIO exitSuccess @@ -5810,14 +5813,15 @@ chatCommandP = "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, + "/set device name " *> (SetLocalDeviceName <$> textP), "/create remote host" $> CreateRemoteHost, "/list remote hosts" $> ListRemoteHosts, "/start remote host " *> (StartRemoteHost <$> A.decimal), "/stop remote host " *> (StopRemoteHost <$> A.decimal), "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), "/start remote ctrl" $> StartRemoteCtrl, - -- TODO *** you need to pass multiple parameters here - "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP)), + "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP <* A.space <*> textP)), + "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), "/list remote ctrls" $> ListRemoteCtrls, "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 5448f49603..2a2b7cff98 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -179,6 +179,7 @@ data ChatController = ChatController sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), currentCalls :: TMap ContactId Call, + localDeviceName :: TVar Text, remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers config :: ChatConfig, @@ -419,6 +420,7 @@ data ChatCommand | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) + | SetLocalDeviceName Text | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost RemoteHostId -- ^ Start and announce a remote host @@ -629,9 +631,9 @@ data ChatResponse | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} - | CRRemoteHostCreated {remoteHostId :: RemoteHostId, oobData :: RemoteCtrlOOB} - | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} -- XXX: RemoteHostInfo is mostly concerned with session setup - | CRRemoteHostConnected {remoteHostId :: RemoteHostId} + | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} + | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} + | CRRemoteHostConnected {remoteHostId :: RemoteHostId} -- TODO add displayName | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} @@ -692,7 +694,8 @@ logResponseToFile = \case _ -> False data RemoteCtrlOOB = RemoteCtrlOOB - { caFingerprint :: C.KeyHash + { caFingerprint :: C.KeyHash, + displayName :: Text } deriving (Show, Generic, FromJSON) @@ -702,6 +705,7 @@ data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, storePath :: FilePath, displayName :: Text, + remoteCtrlOOB :: RemoteCtrlOOB, sessionActive :: Bool } deriving (Show, Generic, FromJSON) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 37283511fc..4d031634ff 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -62,7 +62,7 @@ import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), defaultHTTP2BufferSize import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 -import Simplex.Messaging.Util (bshow, ifM, tshow) +import Simplex.Messaging.Util (bshow, ifM, tshow, ($>>=)) import System.FilePath (isPathSeparator, takeFileName, ()) import UnliftIO import UnliftIO.Directory (createDirectoryIfMissing, getFileSize) @@ -153,14 +153,15 @@ cancelRemoteHostSession = \case RemoteHostSessionStarting {announcer} -> cancel announcer RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient -createRemoteHost :: (ChatMonad m) => m (RemoteHostId, RemoteCtrlOOB) +createRemoteHost :: (ChatMonad m) => m RemoteHostInfo createRemoteHost = do - let displayName = "TODO" -- you don't have remote host name here, it will be passed from remote host - ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) displayName + let hostDisplayName = "TODO" -- you don't have remote host name here, it will be passed from remote host + ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) hostDisplayName storePath <- liftIO randomStorePath - remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath displayName caKey caCert - let oobData = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert} - pure (remoteHostId, oobData) + remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath hostDisplayName caKey caCert + displayName <- chatReadVar localDeviceName + let remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName} + pure RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive = False} -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -168,12 +169,14 @@ randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 listRemoteHosts :: (ChatMonad m) => m [RemoteHostInfo] listRemoteHosts = do - stored <- withStore' getRemoteHosts active <- chatReadVar remoteHostSessions - pure $ do - RemoteHost {remoteHostId, storePath, displayName} <- stored - let sessionActive = M.member remoteHostId active - pure RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} + rcName <- chatReadVar localDeviceName + map (rhInfo active rcName) <$> withStore' getRemoteHosts + where + rhInfo active rcName RemoteHost {remoteHostId, storePath, displayName, caCert} = + let sessionActive = M.member remoteHostId active + remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName = rcName} + in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m () deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do @@ -442,22 +445,20 @@ discoverRemoteCtrls discovered = Discovery.withListener go _nonV4 -> go sock registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m RemoteCtrlId -registerRemoteCtrl RemoteCtrlOOB {caFingerprint} = do - let displayName = "TODO" -- maybe include into OOB data +registerRemoteCtrl RemoteCtrlOOB {caFingerprint, displayName} = do remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint pure remoteCtrlId listRemoteCtrls :: (ChatMonad m) => m [RemoteCtrlInfo] listRemoteCtrls = do - stored <- withStore' getRemoteCtrls active <- - chatReadVar remoteCtrlSession >>= \case - Nothing -> pure Nothing - Just RemoteCtrlSession {accepted} -> atomically (tryReadTMVar accepted) - pure $ do - RemoteCtrl {remoteCtrlId, displayName} <- stored - let sessionActive = active == Just remoteCtrlId - pure RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} + chatReadVar remoteCtrlSession + $>>= \RemoteCtrlSession {accepted} -> atomically $ tryReadTMVar accepted + map (rcInfo active) <$> withStore' getRemoteCtrls + where + rcInfo active RemoteCtrl {remoteCtrlId, displayName} = + let sessionActive = active == Just remoteCtrlId + in RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () acceptRemoteCtrl remoteCtrlId = do @@ -479,10 +480,7 @@ stopRemoteCtrl :: (ChatMonad m) => m () stopRemoteCtrl = chatReadVar remoteCtrlSession >>= \case Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just rcs -> do - cancelRemoteCtrlSession rcs $ do - chatWriteVar remoteCtrlSession Nothing - toView CRRemoteCtrlStopped + Just rcs -> cancelRemoteCtrlSession rcs $ chatWriteVar remoteCtrlSession Nothing cancelRemoteCtrlSession_ :: (MonadUnliftIO m) => RemoteCtrlSession -> m () cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b5dce1ba84..d6826c8774 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -262,7 +262,7 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] - CRRemoteHostCreated rhId oobData -> ("remote host " <> sShow rhId <> " created") : viewRemoteCtrlOOBData oobData + CRRemoteHostCreated RemoteHostInfo {remoteHostId, remoteCtrlOOB} -> ("remote host " <> sShow remoteHostId <> " created") : viewRemoteCtrlOOBData remoteCtrlOOB CRRemoteHostList hs -> viewRemoteHosts hs CRRemoteHostConnected rhId -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] @@ -320,14 +320,14 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei | otherwise = [] ttyUserPrefix :: User -> [StyledString] -> [StyledString] ttyUserPrefix _ [] = [] - ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst prefix ss + ttyUserPrefix User {userId, localDisplayName = u} ss + | null prefix = ss + | otherwise = prependFirst ("[" <> mconcat prefix <> "] ") ss where - prefix = if outputRH /= currentRH then r else userPrefix - r = case outputRH of - Nothing -> "[local] " <> userPrefix - Just rh -> "[remote: ]" <> highlight (show rh) <> "] " - userPrefix = if Just userId /= currentUserId then "[user: " <> highlight u <> "] " else "" - currentUserId = fmap (\User {userId} -> userId) user_ + prefix = intersperse ", " $ remotePrefix <> userPrefix + remotePrefix = [maybe "local" (("remote: " <>) . highlight . show) outputRH | outputRH /= currentRH] + userPrefix = ["user: " <> highlight u | Just userId /= currentUserId] + currentUserId = (\User {userId = uId} -> uId) <$> user_ ttyUser' :: Maybe User -> [StyledString] -> [StyledString] ttyUser' = maybe id ttyUser ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString] diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 68ef6788e9..5bc1845803 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -120,15 +120,15 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do fingerprint' `shouldBe` fingerprint mobile ##> "/list remote ctrls" mobile <## "No remote controllers" - mobile ##> ("/register remote ctrl " <> fingerprint') + mobile ##> ("/register remote ctrl " <> fingerprint' <> " " <> "My desktop") mobile <## "remote controller 1 registered" mobile ##> "/list remote ctrls" mobile <## "Remote controllers:" - mobile <## "1. TODO" + mobile <## "1. My desktop" mobile ##> "/accept remote ctrl 1" mobile <## "ok" -- alternative scenario: accepted before controller start - mobile <## "remote controller 1 connecting to TODO" - mobile <## "remote controller 1 connected, TODO" + mobile <## "remote controller 1 connecting to My desktop" + mobile <## "remote controller 1 connected, My desktop" traceM " - Session active" desktop ##> "/list remote hosts" @@ -136,7 +136,7 @@ remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop <## "1. TODO (active)" mobile ##> "/list remote ctrls" mobile <## "Remote controllers:" - mobile <## "1. TODO (active)" + mobile <## "1. My desktop (active)" traceM " - Shutting desktop" desktop ##> "/stop remote host 1" @@ -181,12 +181,12 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob mobile <## "connection code:" fingerprint' <- getTermLine mobile fingerprint' `shouldBe` fingerprint - mobile ##> ("/register remote ctrl " <> fingerprint') + mobile ##> ("/register remote ctrl " <> fingerprint' <> " " <> "My desktop") mobile <## "remote controller 1 registered" mobile ##> "/accept remote ctrl 1" mobile <## "ok" -- alternative scenario: accepted before controller start - mobile <## "remote controller 1 connecting to TODO" - mobile <## "remote controller 1 connected, TODO" + mobile <## "remote controller 1 connecting to My desktop" + mobile <## "remote controller 1 connected, My desktop" desktop <## "remote host 1 connected" traceM " - exchanging contacts" diff --git a/tests/Test.hs b/tests/Test.hs index 6af51a0724..071ff3791e 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -33,7 +33,7 @@ main = do describe "SimpleX chat client" chatTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests - fdescribe "Remote session" remoteTests + describe "Remote session" remoteTests where testBracket test = do t <- getSystemTime From 41b86e07f18b0cceee3a881cfb0ce1b17cea8450 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 00:18:04 +0100 Subject: [PATCH 078/294] core: update api (#3221) --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- apps/ios/SimpleXChat/APITypes.swift | 19 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 149 ++++++++++++------ src/Simplex/Chat.hs | 3 +- src/Simplex/Chat/Controller.hs | 38 +---- src/Simplex/Chat/Remote.hs | 52 +++--- src/Simplex/Chat/Remote/Types.hs | 32 +++- src/Simplex/Chat/Store/Remote.hs | 12 +- src/Simplex/Chat/View.hs | 16 +- 9 files changed, 199 insertions(+), 126 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ad76364e96..0089fd0879 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -886,9 +886,9 @@ func startRemoteCtrl() async throws { try await sendCommandOkResp(.startRemoteCtrl) } -func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> Int64 { +func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> RemoteCtrlInfo { let r = await chatSendCmd(.registerRemoteCtrl(remoteCtrlOOB: remoteCtrlOOB)) - if case let .remoteCtrlRegistered(rcId) = r { return rcId } + if case let .remoteCtrlRegistered(rcInfo) = r { return rcInfo } throw r } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4b79800e1a..756ab3034f 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -117,6 +117,7 @@ public enum ChatCommand { case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) case setFileToReceive(fileId: Int64, encrypted: Bool) case cancelFile(fileId: Int64) + case setLocalDeviceName(displayName: String) case startRemoteCtrl case registerRemoteCtrl(remoteCtrlOOB: RemoteCtrlOOB) case listRemoteCtrls @@ -262,6 +263,7 @@ public enum ChatCommand { return s case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))" case let .cancelFile(fileId): return "/fcancel \(fileId)" + case let .setLocalDeviceName(displayName): return "/set device name \(displayName)" case .startRemoteCtrl: return "/start remote ctrl" case let .registerRemoteCtrl(oob): return "/register remote ctrl \(oob.caFingerprint)" case let .acceptRemoteCtrl(rcId): return "/accept remote ctrl \(rcId)" @@ -381,6 +383,7 @@ public enum ChatCommand { case .receiveFile: return "receiveFile" case .setFileToReceive: return "setFileToReceive" case .cancelFile: return "cancelFile" + case .setLocalDeviceName: return "setLocalDeviceName" case .startRemoteCtrl: return "startRemoteCtrl" case .registerRemoteCtrl: return "registerRemoteCtrl" case .listRemoteCtrls: return "listRemoteCtrls" @@ -585,11 +588,11 @@ public enum ChatResponse: Decodable, Error { case newContactConnection(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) - case remoteCtrlRegistered(remoteCtrlId: Int64) + case remoteCtrlRegistered(remoteCtrl: RemoteCtrlInfo) case remoteCtrlAnnounce(fingerprint: String) - case remoteCtrlFound(remoteCtrl: RemoteCtrl) - case remoteCtrlConnecting(remoteCtrlId: Int64, displayName: String) - case remoteCtrlConnected(remoteCtrlId: Int64, displayName: String) + case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlConnecting(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) case remoteCtrlStopped case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) @@ -874,11 +877,11 @@ public enum ChatResponse: Decodable, Error { case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlRegistered(rcId): return "remote ctrl ID: \(rcId)" + case let .remoteCtrlRegistered(remoteCtrl): return String(describing: remoteCtrl) case let .remoteCtrlAnnounce(fingerprint): return "fingerprint: \(fingerprint)" - case let .remoteCtrlFound(remoteCtrl): return "remote ctrl: \(String(describing: remoteCtrl))" - case let .remoteCtrlConnecting(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)" - case let .remoteCtrlConnected(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)" + case let .remoteCtrlFound(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlConnecting(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) case .remoteCtrlStopped: return noDetails case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" case .cmdOk: return noDetails diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 9ab6060fe9..f128fcb75f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -166,6 +166,7 @@ class AppPreferences { val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null) val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0) val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300) + val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, "Desktop") private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -306,6 +307,7 @@ class AppPreferences { private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion" private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode" private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime" + private const val SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS = "DeviceNameForRemoteAccess" } } @@ -342,6 +344,11 @@ object ChatController { val users = listUsers() chatModel.users.clear() chatModel.users.addAll(users) + val remoteHosts = listRemoteHosts() + if (remoteHosts != null) { + chatModel.remoteHosts.clear() + chatModel.remoteHosts.addAll(remoteHosts) + } if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true @@ -432,15 +439,16 @@ object ChatController { } } - private fun recvMsg(ctrl: ChatCtrl): CR? { + private fun recvMsg(ctrl: ChatCtrl): APIResponse? { val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) return if (json == "") { null } else { - val r = APIResponse.decodeStr(json).resp + val apiResp = APIResponse.decodeStr(json) + val r = apiResp.resp Log.d(TAG, "chatRecvMsg: ${r.responseType}") if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json") - r + apiResp } } @@ -1327,6 +1335,59 @@ object ChatController { } } + suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(CC.SetLocalDeviceName(displayName)) + + suspend fun createRemoteHost(): RemoteHostInfo? { + val r = sendCmd(CC.CreateRemoteHost()) + if (r is CR.RemoteHostCreated) return r.remoteHost + apiErrorAlert("createRemoteHost", generalGetString(MR.strings.error), r) + return null + } + + suspend fun listRemoteHosts(): List? { + val r = sendCmd(CC.ListRemoteHosts()) + if (r is CR.RemoteHostList) return r.remoteHosts + apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error), r) + return null + } + + suspend fun startRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StartRemoteHost(rhId)) + + suspend fun registerRemoteCtrl(oob: RemoteCtrlOOB): RemoteCtrlInfo? { + val r = sendCmd(CC.RegisterRemoteCtrl(oob)) + if (r is CR.RemoteCtrlRegistered) return r.remoteCtrl + apiErrorAlert("registerRemoteCtrl", generalGetString(MR.strings.error), r) + return null + } + + suspend fun listRemoteCtrls(): List? { + val r = sendCmd(CC.ListRemoteCtrls()) + if (r is CR.RemoteCtrlList) return r.remoteCtrls + apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error), r) + return null + } + + suspend fun stopRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId)) + + suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId)) + + suspend fun startRemoteCtrl(): Boolean = sendCommandOkResp(CC.StartRemoteCtrl()) + + suspend fun acceptRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.AcceptRemoteCtrl(rcId)) + + suspend fun rejectRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.RejectRemoteCtrl(rcId)) + + suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(CC.StopRemoteCtrl()) + + suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteCtrl(rcId)) + + private suspend fun sendCommandOkResp(cmd: CC): Boolean { + val r = sendCmd(cmd) + val ok = r is CR.CmdOk + if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error), r) + return ok + } + suspend fun apiGetVersion(): CoreVersionInfo? { val r = sendCmd(CC.ShowVersion()) return if (r is CR.VersionInfo) { @@ -1361,14 +1422,15 @@ object ChatController { } } - fun apiErrorAlert(method: String, title: String, r: CR) { + private fun apiErrorAlert(method: String, title: String, r: CR) { val errMsg = "${r.responseType}: ${r.details}" Log.e(TAG, "$method bad response: $errMsg") AlertManager.shared.showAlertMsg(title, errMsg) } - suspend fun processReceivedMsg(r: CR) { + private suspend fun processReceivedMsg(apiResp: APIResponse) { lastMsgReceivedTimestamp = System.currentTimeMillis() + val r = apiResp.resp chatModel.addTerminalItem(TerminalItem.resp(r)) when (r) { is CR.NewContactConnection -> { @@ -1674,6 +1736,13 @@ object ChatController { chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats) is CR.GroupMemberRatchetSync -> chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + is CR.RemoteHostConnected -> { + // update + chatModel.connectingRemoteHost.value = r.remoteHost + } + is CR.RemoteHostStopped -> { + // + } else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -1933,6 +2002,7 @@ sealed class CC { class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() + class SetLocalDeviceName(val displayName: String): CC() class CreateRemoteHost(): CC() class ListRemoteHosts(): CC() class StartRemoteHost(val remoteHostId: Long): CC() @@ -2053,13 +2123,14 @@ sealed class CC { is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" + is SetLocalDeviceName -> "/set device name $displayName" is CreateRemoteHost -> "/create remote host" is ListRemoteHosts -> "/list remote hosts" is StartRemoteHost -> "/start remote host $remoteHostId" is StopRemoteHost -> "/stop remote host $remoteHostId" is DeleteRemoteHost -> "/delete remote host $remoteHostId" is StartRemoteCtrl -> "/start remote ctrl" - is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.caFingerprint}" + is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.fingerprint}" is AcceptRemoteCtrl -> "/accept remote ctrl $remoteCtrlId" is RejectRemoteCtrl -> "/reject remote ctrl $remoteCtrlId" is ListRemoteCtrls -> "/list remote ctrls" @@ -2162,6 +2233,7 @@ sealed class CC { is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" is CancelFile -> "cancelFile" + is SetLocalDeviceName -> "setLocalDeviceName" is CreateRemoteHost -> "createRemoteHost" is ListRemoteHosts -> "listRemoteHosts" is StartRemoteHost -> "startRemoteHost" @@ -3246,7 +3318,8 @@ data class RemoteCtrl ( @Serializable data class RemoteCtrlOOB ( - val caFingerprint: String + val fingerprint: String, + val displayName: String ) @Serializable @@ -3261,6 +3334,7 @@ data class RemoteHostInfo ( val remoteHostId: Long, val storePath: String, val displayName: String, + val remoteCtrlOOB: RemoteCtrlOOB, val sessionActive: Boolean ) @@ -3277,7 +3351,7 @@ val yaml = Yaml(configuration = YamlConfiguration( )) @Serializable -class APIResponse(val resp: CR, val corr: String? = null) { +class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = null) { companion object { fun decodeStr(str: String): APIResponse { return try { @@ -3287,48 +3361,35 @@ class APIResponse(val resp: CR, val corr: String? = null) { Log.d(TAG, e.localizedMessage ?: "") val data = json.parseToJsonElement(str).jsonObject val resp = data["resp"]!!.jsonObject - val type = resp["type"]?.jsonPrimitive?.content ?: "invalid" + val type = resp["type"]?.jsonPrimitive?.contentOrNull ?: "invalid" + val corr = data["corr"]?.toString() + val remoteHostId = data["remoteHostId"]?.jsonPrimitive?.longOrNull try { if (type == "apiChats") { val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chats: List = resp["chats"]!!.jsonArray.map { parseChatData(it) } - return APIResponse( - resp = CR.ApiChats(user, chats), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ApiChats(user, chats), remoteHostId, corr) } else if (type == "apiChat") { val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chat = parseChatData(resp["chat"]!!) - return APIResponse( - resp = CR.ApiChat(user, chat), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ApiChat(user, chat), remoteHostId, corr) } else if (type == "chatCmdError") { val userObject = resp["user_"]?.jsonObject val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse( - resp = CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) } else if (type == "chatError") { val userObject = resp["user_"]?.jsonObject val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse( - resp = CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) } } catch (e: Exception) { Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString()) } - APIResponse( - resp = CR.Response(type, json.encodeToString(data)), - corr = data["corr"]?.toString() - ) + APIResponse(CR.Response(type, json.encodeToString(data)), remoteHostId, corr) } catch(e: Exception) { - APIResponse(CR.Invalid(str)) + APIResponse(CR.Invalid(str), remoteHostId = null) } } } @@ -3484,17 +3545,17 @@ sealed class CR { @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR() // remote events (desktop) - @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHostId: Long, val oobData: RemoteCtrlOOB): CR() + @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() - @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() // remote events (mobile) @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() - @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrlId: Long): CR() + @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrl: RemoteCtrlInfo): CR() @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() - @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrl): CR() - @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrlId: Long, val displayName: String): CR() - @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrlId: Long, val displayName: String): CR() + @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo): CR() + @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl: RemoteCtrlInfo): CR() + @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR() @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() @@ -3767,17 +3828,17 @@ sealed class CR { is CallEnded -> withUser(user, "contact: ${contact.id}") is NewContactConnection -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) - is RemoteHostCreated -> "remote host ID: $remoteHostId\noobData ${json.encodeToString(oobData)}" - is RemoteHostList -> "remote hosts: ${json.encodeToString(remoteHosts)}" - is RemoteHostConnected -> "remote host ID: $remoteHostId" + is RemoteHostCreated -> json.encodeToString(remoteHost) + is RemoteHostList -> json.encodeToString(remoteHosts) + is RemoteHostConnected -> json.encodeToString(remoteHost) is RemoteHostStopped -> "remote host ID: $remoteHostId" is RemoteCtrlList -> json.encodeToString(remoteCtrls) - is RemoteCtrlRegistered -> "remote ctrl ID: $remoteCtrlId" + is RemoteCtrlRegistered -> json.encodeToString(remoteCtrl) is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" - is RemoteCtrlFound -> "remote ctrl: ${json.encodeToString(remoteCtrl)}" - is RemoteCtrlConnecting -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" - is RemoteCtrlConnected -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName" - is RemoteCtrlStopped -> "" + is RemoteCtrlFound -> json.encodeToString(remoteCtrl) + is RemoteCtrlConnecting -> json.encodeToString(remoteCtrl) + is RemoteCtrlConnected -> json.encodeToString(remoteCtrl) + is RemoteCtrlStopped -> noDetails() is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" + "chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" + "agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}" diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 726fbdce88..9c25afbd51 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -70,6 +70,7 @@ import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.Remote import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -1900,7 +1901,7 @@ processChatCommand = \case StopRemoteHost rh -> closeRemoteHostSession rh >> ok_ DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_ - RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> registerRemoteCtrl oob + RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_ RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_ StopRemoteCtrl -> stopRemoteCtrl >> ok_ diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2a2b7cff98..22c2649f5a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -633,14 +633,14 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} - | CRRemoteHostConnected {remoteHostId :: RemoteHostId} -- TODO add displayName + | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId} + | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation - | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrl} -- registered fingerprint, may connect - | CRRemoteCtrlConnecting {remoteCtrlId :: RemoteCtrlId, displayName :: Text} - | CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text} + | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect + | CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo} + | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} @@ -693,34 +693,6 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False -data RemoteCtrlOOB = RemoteCtrlOOB - { caFingerprint :: C.KeyHash, - displayName :: Text - } - deriving (Show, Generic, FromJSON) - -instance ToJSON RemoteCtrlOOB where toEncoding = J.genericToEncoding J.defaultOptions - -data RemoteHostInfo = RemoteHostInfo - { remoteHostId :: RemoteHostId, - storePath :: FilePath, - displayName :: Text, - remoteCtrlOOB :: RemoteCtrlOOB, - sessionActive :: Bool - } - deriving (Show, Generic, FromJSON) - -instance ToJSON RemoteHostInfo where toEncoding = J.genericToEncoding J.defaultOptions - -data RemoteCtrlInfo = RemoteCtrlInfo - { remoteCtrlId :: RemoteCtrlId, - displayName :: Text, - sessionActive :: Bool - } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON RemoteCtrlInfo where toEncoding = J.genericToEncoding J.defaultOptions - data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 4d031634ff..256e00d6d4 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -33,6 +33,7 @@ import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) +import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeUtf8, encodeUtf8) import qualified Network.HTTP.Types as HTTP @@ -93,7 +94,7 @@ startRemoteHost remoteHostId = do M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case Nothing -> logInfo $ "Session already closed for remote host " <> tshow remoteHostId Just _ -> closeRemoteHostSession remoteHostId >> toView (CRRemoteHostStopped remoteHostId) - run RemoteHost {storePath, caKey, caCert} = do + run rh@RemoteHost {storePath, caKey, caCert} = do finished <- newTVarIO False let parent = (C.signatureKeyPair caKey, caCert) sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" @@ -120,7 +121,9 @@ startRemoteHost remoteHostId = do Nothing -> toViewRemote chatResponse Just localFile -> toViewRemote CRRcvFileComplete {user = ru, chatItem = AChatItem c d i ci {file = Just localFile}} _ -> toViewRemote chatResponse - toView CRRemoteHostConnected {remoteHostId} + rcName <- chatReadVar localDeviceName + -- TODO what sets session active? + toView CRRemoteHostConnected {remoteHost = remoteHostInfo rh True rcName} sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response) sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing) @@ -155,13 +158,13 @@ cancelRemoteHostSession = \case createRemoteHost :: (ChatMonad m) => m RemoteHostInfo createRemoteHost = do - let hostDisplayName = "TODO" -- you don't have remote host name here, it will be passed from remote host - ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) hostDisplayName + let rhName = "TODO" -- you don't have remote host name here, it will be passed from remote host + ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) rhName storePath <- liftIO randomStorePath - remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath hostDisplayName caKey caCert - displayName <- chatReadVar localDeviceName - let remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName} - pure RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive = False} + remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath rhName caKey caCert + rcName <- chatReadVar localDeviceName + let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} + pure RemoteHostInfo {remoteHostId, storePath, displayName = rhName, remoteCtrlOOB, sessionActive = False} -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -173,10 +176,13 @@ listRemoteHosts = do rcName <- chatReadVar localDeviceName map (rhInfo active rcName) <$> withStore' getRemoteHosts where - rhInfo active rcName RemoteHost {remoteHostId, storePath, displayName, caCert} = - let sessionActive = M.member remoteHostId active - remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName = rcName} - in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} + rhInfo active rcName rh@RemoteHost {remoteHostId} = + remoteHostInfo rh (M.member remoteHostId active) rcName + +remoteHostInfo :: RemoteHost -> Bool -> Text -> RemoteHostInfo +remoteHostInfo RemoteHost {remoteHostId, storePath, displayName, caCert} sessionActive rcName = + let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} + in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m () deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do @@ -405,13 +411,13 @@ startRemoteCtrl execChatCommand = accepted <- newEmptyTMVarIO supervisor <- async $ do remoteCtrlId <- atomically (readTMVar accepted) - withRemoteCtrl remoteCtrlId $ \RemoteCtrl {displayName, fingerprint} -> do + withRemoteCtrl remoteCtrlId $ \rc@RemoteCtrl {fingerprint} -> do source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure - toView $ CRRemoteCtrlConnecting {remoteCtrlId, displayName} + toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False atomically $ writeTVar discovered mempty -- flush unused sources server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} - toView $ CRRemoteCtrlConnected {remoteCtrlId, displayName} + toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True _ <- waitCatch server chatWriteVar remoteCtrlSession Nothing toView CRRemoteCtrlStopped @@ -436,7 +442,7 @@ discoverRemoteCtrls discovered = Discovery.withListener go withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of - Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui "accept" action required + Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required Just False -> pure () -- skipping a rejected item Just True -> chatReadVar remoteCtrlSession >>= \case @@ -444,11 +450,6 @@ discoverRemoteCtrls discovered = Discovery.withListener go Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically _nonV4 -> go sock -registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m RemoteCtrlId -registerRemoteCtrl RemoteCtrlOOB {caFingerprint, displayName} = do - remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint - pure remoteCtrlId - listRemoteCtrls :: (ChatMonad m) => m [RemoteCtrlInfo] listRemoteCtrls = do active <- @@ -456,9 +457,12 @@ listRemoteCtrls = do $>>= \RemoteCtrlSession {accepted} -> atomically $ tryReadTMVar accepted map (rcInfo active) <$> withStore' getRemoteCtrls where - rcInfo active RemoteCtrl {remoteCtrlId, displayName} = - let sessionActive = active == Just remoteCtrlId - in RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive} + rcInfo active rc@RemoteCtrl {remoteCtrlId} = + remoteCtrlInfo rc $ active == Just remoteCtrlId + +remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo +remoteCtrlInfo RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} sessionActive = + RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted, sessionActive} acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () acceptRemoteCtrl remoteCtrlId = do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index cdff2b7acc..67fe7c6ffa 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -24,6 +24,25 @@ data RemoteHost = RemoteHost } deriving (Show) +data RemoteCtrlOOB = RemoteCtrlOOB + { fingerprint :: C.KeyHash, + displayName :: Text + } + deriving (Show) + +$(J.deriveJSON J.defaultOptions ''RemoteCtrlOOB) + +data RemoteHostInfo = RemoteHostInfo + { remoteHostId :: RemoteHostId, + storePath :: FilePath, + displayName :: Text, + remoteCtrlOOB :: RemoteCtrlOOB, + sessionActive :: Bool + } + deriving (Show) + +$(J.deriveJSON J.defaultOptions ''RemoteHostInfo) + type RemoteCtrlId = Int64 data RemoteCtrl = RemoteCtrl @@ -34,4 +53,15 @@ data RemoteCtrl = RemoteCtrl } deriving (Show) -$(J.deriveJSON J.defaultOptions ''RemoteCtrl) +$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrl) + +data RemoteCtrlInfo = RemoteCtrlInfo + { remoteCtrlId :: RemoteCtrlId, + displayName :: Text, + fingerprint :: C.KeyHash, + accepted :: Maybe Bool, + sessionActive :: Bool + } + deriving (Show) + +$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrlInfo) diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index c231a535b5..9189a27769 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -9,14 +9,15 @@ import Data.Text (Text) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Chat.Remote.Types (RemoteCtrl (..), RemoteCtrlId, RemoteHost (..), RemoteHostId) +import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.Remote.Types import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) import qualified Simplex.Messaging.Crypto as C insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId insertRemoteHost db storePath displayName caKey caCert = do DB.execute db "INSERT INTO remote_hosts (store_path, display_name, ca_key, ca_cert) VALUES (?,?,?,?)" (storePath, displayName, caKey, C.SignedObject caCert) - fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" + insertedRowId db getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts db = @@ -37,10 +38,11 @@ toRemoteHost (remoteHostId, storePath, displayName, caKey, C.SignedObject caCert deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) -insertRemoteCtrl :: DB.Connection -> Text -> C.KeyHash -> IO RemoteCtrlId -insertRemoteCtrl db displayName fingerprint = do +insertRemoteCtrl :: DB.Connection -> RemoteCtrlOOB -> IO RemoteCtrlInfo +insertRemoteCtrl db RemoteCtrlOOB {fingerprint, displayName} = do DB.execute db "INSERT INTO remote_controllers (display_name, fingerprint) VALUES (?,?)" (displayName, fingerprint) - fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" + remoteCtrlId <- insertedRowId db + pure RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted = Nothing, sessionActive = False} getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] getRemoteCtrls db = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d6826c8774..51dcd0c6b6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -264,14 +264,14 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRNtfMessages {} -> [] CRRemoteHostCreated RemoteHostInfo {remoteHostId, remoteCtrlOOB} -> ("remote host " <> sShow remoteHostId <> " created") : viewRemoteCtrlOOBData remoteCtrlOOB CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostConnected rhId -> ["remote host " <> sShow rhId <> " connected"] + CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlRegistered rcId -> ["remote controller " <> sShow rcId <> " registered"] + CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> ["remote controller " <> sShow rcId <> " registered"] CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlConnecting rcId rcName -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] - CRRemoteCtrlConnected rcId rcName -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] + CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] + CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] CRRemoteCtrlStopped -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> @@ -1633,8 +1633,8 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo parens s = " (" <> s <> ")" viewRemoteCtrlOOBData :: RemoteCtrlOOB -> [StyledString] -viewRemoteCtrlOOBData RemoteCtrlOOB {caFingerprint} = - ["connection code:", plain $ strEncode caFingerprint] +viewRemoteCtrlOOBData RemoteCtrlOOB {fingerprint} = + ["connection code:", plain $ strEncode fingerprint] viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] viewRemoteHosts = \case @@ -1653,8 +1653,8 @@ viewRemoteCtrls = \case plain $ tshow remoteCtrlId <> ". " <> displayName <> if sessionActive then " (active)" else "" -- TODO fingerprint, accepted? -viewRemoteCtrl :: RemoteCtrl -> StyledString -viewRemoteCtrl RemoteCtrl {remoteCtrlId, displayName} = +viewRemoteCtrl :: RemoteCtrlInfo -> StyledString +viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, displayName} = plain $ tshow remoteCtrlId <> ". " <> displayName viewChatError :: ChatLogLevel -> ChatError -> [StyledString] From fc1bba8817c93c51c03fa0d905ec87f5cf3bbbb2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 14:17:36 +0100 Subject: [PATCH 079/294] remote: refactor (WIP) (#3222) * remote: refactor (WIP) * refactor discoverRemoteCtrls * refactor processRemoteCommand, storeRemoteFile * refactor fetchRemoteFile * refactor startRemoteHost, receiving files * refactor relayCommand --- apps/ios/SimpleXChat/APITypes.swift | 1 - .../chat/simplex/common/model/SimpleXAPI.kt | 2 - src/Simplex/Chat.hs | 5 +- src/Simplex/Chat/Controller.hs | 15 +- src/Simplex/Chat/Remote.hs | 479 +++++++++--------- src/Simplex/Chat/Remote/Discovery.hs | 6 +- src/Simplex/Chat/Store/Remote.hs | 13 +- src/Simplex/Chat/Store/Shared.hs | 3 + tests/RemoteTests.hs | 4 +- 9 files changed, 274 insertions(+), 254 deletions(-) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 756ab3034f..761c1daf7c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1804,7 +1804,6 @@ public enum ArchiveError: Decodable { } public enum RemoteCtrlError: Decodable { - case missing(remoteCtrlId: Int64) case inactive case busy case timeout diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index f128fcb75f..1f5cc09d47 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -4521,7 +4521,6 @@ sealed class RemoteHostError { @Serializable sealed class RemoteCtrlError { val string: String get() = when (this) { - is Missing -> "missing" is Inactive -> "inactive" is Busy -> "busy" is Timeout -> "timeout" @@ -4531,7 +4530,6 @@ sealed class RemoteCtrlError { is CertificateUntrusted -> "certificateUntrusted" is BadFingerprint -> "badFingerprint" } - @Serializable @SerialName("missing") class Missing(val remoteCtrlId: Long): RemoteCtrlError() @Serializable @SerialName("inactive") object Inactive: RemoteCtrlError() @Serializable @SerialName("busy") object Busy: RemoteCtrlError() @Serializable @SerialName("timeout") object Timeout: RemoteCtrlError() diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9c25afbd51..e1bd795b38 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -397,7 +397,7 @@ execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> (ByteString, ChatCommand) -> m ChatResponse -execRemoteCommand u rh scmd = either (CRChatCmdError u) id <$> runExceptT (withRemoteHostSession rh $ \rhs -> processRemoteCommand rhs scmd) +execRemoteCommand u rhId scmd = either (CRChatCmdError u) id <$> runExceptT (getRemoteHostSession rhId >>= (`processRemoteCommand` scmd)) parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace @@ -5154,9 +5154,6 @@ closeFileHandle fileId files = do h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m) liftIO $ mapM_ hClose h_ `catchAll_` pure () -throwChatError :: ChatMonad m => ChatErrorType -> m a -throwChatError = throwError . ChatError - deleteMembersConnections :: ChatMonad m => User -> [GroupMember] -> m () deleteMembersConnections user members = do let memberConns = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 22c2649f5a..78848cca22 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1158,8 +1158,7 @@ instance ToJSON RemoteHostError where -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteCtrlError - = RCEMissing {remoteCtrlId :: RemoteCtrlId} -- ^ No remote session matches this identifier - | RCEInactive -- ^ No session is running + = RCEInactive -- ^ No session is running | RCEBusy -- ^ A session is already running | RCETimeout -- ^ Remote operation timed out | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller @@ -1167,6 +1166,9 @@ data RemoteCtrlError | RCECertificateExpired {remoteCtrlId :: RemoteCtrlId} -- ^ A connection or CA certificate in a chain have bad validity period | RCECertificateUntrusted {remoteCtrlId :: RemoteCtrlId} -- ^ TLS is unable to validate certificate chain presented for a connection | RCEBadFingerprint -- ^ Bad fingerprint data provided in OOB + | RCEHTTP2Error {http2Error :: String} + | RCEHTTP2RespStatus {statusCode :: Maybe Int} -- TODO remove + | RCEInvalidResponse {responseError :: String} deriving (Show, Exception, Generic) instance FromJSON RemoteCtrlError where @@ -1199,7 +1201,7 @@ data RemoteHostSession } data RemoteCtrlSession = RemoteCtrlSession - { -- | Server side of transport to process remote commands and forward notifications + { -- | Host (mobile) side of transport to process remote commands and forward notifications discoverer :: Async (), supervisor :: Async (), hostServer :: Maybe (Async ()), @@ -1239,6 +1241,10 @@ chatFinally :: ChatMonad m => m a -> m b -> m a chatFinally = allFinally mkChatError {-# INLINE chatFinally #-} +onChatError :: ChatMonad m => m a -> m b -> m a +a `onChatError` onErr = a `catchChatError` \e -> onErr >> throwError e +{-# INLINE onChatError #-} + mkChatError :: SomeException -> ChatError mkChatError = ChatError . CEException . show {-# INLINE mkChatError #-} @@ -1246,6 +1252,9 @@ mkChatError = ChatError . CEException . show chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError +throwChatError :: ChatMonad m => ChatErrorType -> m a +throwChatError = throwError . ChatError + -- | Emit local events. toView :: ChatMonad' m => ChatResponse -> m () toView event = do diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 256e00d6d4..336b5d2cf9 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -38,8 +38,8 @@ import qualified Data.Text as T import Data.Text.Encoding (decodeUtf8, encodeUtf8) import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP.Types.Status as Status -import qualified Network.HTTP2.Client as HTTP2Client -import qualified Network.HTTP2.Server as HTTP2Server +import qualified Network.HTTP2.Client as HC +import qualified Network.HTTP2.Server as HS import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller import Simplex.Chat.Messages (AChatItem (..), CIFile (..), CIFileStatus (..), ChatItem (..), chatNameStr) @@ -60,103 +60,100 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), defaultHTTP2BufferSize) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, HTTP2Response (..)) import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 -import Simplex.Messaging.Util (bshow, ifM, tshow, ($>>=)) +import Simplex.Messaging.Util (bshow, ifM, liftEitherError, liftEitherWith, tshow, ($>>=)) import System.FilePath (isPathSeparator, takeFileName, ()) import UnliftIO import UnliftIO.Directory (createDirectoryIfMissing, getFileSize) -withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a -withRemoteHostSession remoteHostId action = do - chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteHostId +getRemoteHostSession :: ChatMonad m => RemoteHostId -> m RemoteHostSession +getRemoteHostSession rhId = chatReadVar remoteHostSessions >>= maybe err pure . M.lookup rhId where - err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing + err = throwError $ ChatErrorRemoteHost rhId RHMissing -withRemoteHost :: (ChatMonad m) => RemoteHostId -> (RemoteHost -> m a) -> m a -withRemoteHost remoteHostId action = - withStore' (`getRemoteHost` remoteHostId) >>= \case - Nothing -> throwError $ ChatErrorRemoteHost remoteHostId RHMissing - Just rh -> action rh +checkNoRemoteHostSession :: ChatMonad m => RemoteHostId -> m () +checkNoRemoteHostSession rhId = chatReadVar remoteHostSessions >>= maybe (pure ()) err . M.lookup rhId + where + err _ = throwError $ ChatErrorRemoteHost rhId RHBusy -startRemoteHost :: (ChatMonad m) => RemoteHostId -> m () -startRemoteHost remoteHostId = do - asks remoteHostSessions >>= atomically . TM.lookup remoteHostId >>= \case - Just _ -> throwError $ ChatErrorRemoteHost remoteHostId RHBusy - Nothing -> withRemoteHost remoteHostId $ \rh -> do - announcer <- async $ run rh - chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarting {announcer} +startRemoteHost :: ChatMonad m => RemoteHostId -> m () +startRemoteHost rhId = do + checkNoRemoteHostSession rhId + rh <- withStore (`getRemoteHost` rhId) + announcer <- async $ do + finished <- newTVarIO False + http <- start rh finished `onChatError` cleanup finished + run rh finished http + chatModifyVar remoteHostSessions $ M.insert rhId RemoteHostSessionStarting {announcer} where cleanup finished = do logInfo "Remote host http2 client fininshed" atomically $ writeTVar finished True - M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case - Nothing -> logInfo $ "Session already closed for remote host " <> tshow remoteHostId - Just _ -> closeRemoteHostSession remoteHostId >> toView (CRRemoteHostStopped remoteHostId) - run rh@RemoteHost {storePath, caKey, caCert} = do - finished <- newTVarIO False + -- TODO why this is not an error? + M.lookup rhId <$> chatReadVar remoteHostSessions >>= \case + Nothing -> logInfo $ "Session already closed for remote host " <> tshow rhId + Just _ -> closeRemoteHostSession rhId >> toView (CRRemoteHostStopped rhId) + start rh@RemoteHost {storePath, caKey, caCert} finished = do let parent = (C.signatureKeyPair caKey, caCert) sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] - Discovery.announceRevHTTP2 (cleanup finished) fingerprint credentials >>= \case - Left h2ce -> do - logError $ "Failed to set up remote host connection: " <> tshow h2ce - cleanup finished - Right ctrlClient -> do - chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarted {storePath, ctrlClient} - chatWriteVar currentRemoteHost $ Just remoteHostId - sendHello ctrlClient >>= \case - Left h2ce -> do - logError $ "Failed to send initial remote host request: " <> tshow h2ce - cleanup finished - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - logDebug $ "Got initial from remote host: " <> tshow bodyHead - oq <- asks outputQ - let toViewRemote = atomically . writeTBQueue oq . (Nothing,Just remoteHostId,) - void . async $ pollRemote finished ctrlClient "/recv" $ \chatResponse -> do - case chatResponse of - CRRcvFileComplete {user = ru, chatItem = AChatItem c d@SMDRcv i ci@ChatItem {file = Just ciFile}} -> do - handleRcvFileComplete ctrlClient storePath ru ciFile >>= \case - Nothing -> toViewRemote chatResponse - Just localFile -> toViewRemote CRRcvFileComplete {user = ru, chatItem = AChatItem c d i ci {file = Just localFile}} - _ -> toViewRemote chatResponse - rcName <- chatReadVar localDeviceName - -- TODO what sets session active? - toView CRRemoteHostConnected {remoteHost = remoteHostInfo rh True rcName} + u <- askUnliftIO + ctrlClient <- liftHTTP2 $ Discovery.announceRevHTTP2 fingerprint credentials $ unliftIO u (cleanup finished) -- >>= \case + chatModifyVar remoteHostSessions $ M.insert rhId RemoteHostSessionStarted {storePath, ctrlClient} + chatWriteVar currentRemoteHost $ Just rhId + HTTP2Response {respBody = HTTP2Body {bodyHead}} <- sendHello ctrlClient + rcName <- chatReadVar localDeviceName + -- TODO what sets session active? + toView CRRemoteHostConnected {remoteHost = remoteHostInfo rh True rcName} + pure ctrlClient + run RemoteHost {storePath} finished ctrlClient = do + oq <- asks outputQ + let toViewRemote = atomically . writeTBQueue oq . (Nothing,Just rhId,) + -- TODO remove REST + void . async $ pollRemote finished ctrlClient "/recv" $ handleFile >=> toViewRemote + where + -- TODO move to view / terminal + handleFile = \case + cr@CRRcvFileComplete {user, chatItem = AChatItem c SMDRcv i ci@ChatItem {file = Just ciFile@CIFile {fileStatus = CIFSRcvComplete}}} -> do + maybe cr update <$> handleRcvFileComplete ctrlClient storePath user ciFile + where + update localFile = cr {chatItem = AChatItem c SMDRcv i ci {file = Just localFile}} + cr -> pure cr -sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response) -sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing) +sendHello :: ChatMonad m => HTTP2Client -> m HTTP2Response +sendHello http = liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing where - req = HTTP2Client.requestNoBody "GET" "/" mempty + req = HC.requestNoBody "GET" "/" mempty -pollRemote :: (ChatMonad m, J.FromJSON a) => TVar Bool -> HTTP2Client -> ByteString -> (a -> m ()) -> m () -pollRemote finished http path action = loop +-- TODO how (on what condition) it would stop polling? +-- TODO add JSON translation +pollRemote :: ChatMonad m => TVar Bool -> HTTP2Client -> ByteString -> (ChatResponse -> m ()) -> m () +pollRemote finished http path action = loop `catchChatError` \e -> action (CRChatError Nothing e) >> loop where loop = do - liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case - Left e -> logError $ "pollRemote: " <> tshow (path, e) - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - logDebug $ "Got /recv response: " <> decodeUtf8 bodyHead - case J.eitherDecodeStrict' bodyHead of - Left e -> logError $ "pollRemote/decode: " <> tshow (path, e) - Right o -> action o + -- TODO this will never load full body + HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing + json <- liftEitherWith (ChatErrorRemoteCtrl . RCEInvalidResponse) $ J.eitherDecodeStrict' bodyHead -- of + action json readTVarIO finished >>= (`unless` loop) - req = HTTP2Client.requestNoBody "GET" path mempty + req = HC.requestNoBody "GET" path mempty -closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m () -closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do +closeRemoteHostSession :: ChatMonad m => RemoteHostId -> m () +closeRemoteHostSession remoteHostId = do + session <- getRemoteHostSession remoteHostId logInfo $ "Closing remote host session for " <> tshow remoteHostId liftIO $ cancelRemoteHostSession session chatWriteVar currentRemoteHost Nothing chatModifyVar remoteHostSessions $ M.delete remoteHostId -cancelRemoteHostSession :: (MonadUnliftIO m) => RemoteHostSession -> m () +cancelRemoteHostSession :: MonadUnliftIO m => RemoteHostSession -> m () cancelRemoteHostSession = \case RemoteHostSessionStarting {announcer} -> cancel announcer RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient -createRemoteHost :: (ChatMonad m) => m RemoteHostInfo +createRemoteHost :: ChatMonad m => m RemoteHostInfo createRemoteHost = do let rhName = "TODO" -- you don't have remote host name here, it will be passed from remote host ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) rhName @@ -170,7 +167,7 @@ createRemoteHost = do randomStorePath :: IO FilePath randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 -listRemoteHosts :: (ChatMonad m) => m [RemoteHostInfo] +listRemoteHosts :: ChatMonad m => m [RemoteHostInfo] listRemoteHosts = do active <- chatReadVar remoteHostSessions rcName <- chatReadVar localDeviceName @@ -184,75 +181,72 @@ remoteHostInfo RemoteHost {remoteHostId, storePath, displayName, caCert} session let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} -deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m () -deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do +deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () +deleteRemoteHost rhId = do + RemoteHost {storePath} <- withStore (`getRemoteHost` rhId) chatReadVar filesFolder >>= \case Just baseDir -> do let hostStore = baseDir storePath logError $ "TODO: remove " <> tshow hostStore Nothing -> logWarn "Local file store not available while deleting remote host" - withStore' $ \db -> deleteRemoteHostRecord db remoteHostId + withStore' (`deleteRemoteHostRecord` rhId) -processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse -processRemoteCommand RemoteHostSessionStarting {} _ = pure . CRChatError Nothing . ChatError $ CEInternalError "sending remote commands before session started" -processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = do - logDebug $ "processRemoteCommand: " <> tshow (s, cmd) - case cmd of - SendFile cn ctrlPath -> do - storeRemoteFile ctrlClient ctrlPath >>= \case - -- TODO: use Left - Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host" - Just hostPath -> relayCommand ctrlClient $ "/file " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath - SendImage cn ctrlPath -> do - storeRemoteFile ctrlClient ctrlPath >>= \case - Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store image on remote host" - Just hostPath -> relayCommand ctrlClient $ "/image " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath - APISendMessage {composedMessage = cm@ComposedMessage {fileSource = Just CryptoFile {filePath = ctrlPath, cryptoArgs}}} -> do - storeRemoteFile ctrlClient ctrlPath >>= \case - Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host" - Just hostPath -> do - let cm' = cm {fileSource = Just CryptoFile {filePath = hostPath, cryptoArgs}} :: ComposedMessage - relayCommand ctrlClient $ B.takeWhile (/= '{') s <> B.toStrict (J.encode cm') - _ -> relayCommand ctrlClient s - -relayCommand :: (ChatMonad m) => HTTP2Client -> ByteString -> m ChatResponse -relayCommand http s = - postBytestring Nothing http "/send" mempty s >>= \case - Left e -> err $ "relayCommand/post: " <> show e - Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do - logDebug $ "Got /send response: " <> decodeUtf8 bodyHead - remoteChatResponse <- case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks - Left e -> err $ "relayCommand/decodeValue: " <> show e - Right json -> case J.fromJSON $ toTaggedJSON json of - J.Error e -> err $ "relayCommand/fromJSON: " <> show e - J.Success cr -> pure cr - case remoteChatResponse of - -- TODO: intercept file responses and fetch files when needed - -- XXX: is that even possible, to have a file response to a command? - _ -> pure remoteChatResponse +processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse +processRemoteCommand RemoteHostSessionStarting {} _ = pure $ chatCmdError Nothing "remote command sent before session started" +processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = + uploadFile cmd >>= relayCommand ctrlClient where - err = pure . CRChatError Nothing . ChatError . CEInternalError + fileCmd cmdPfx cn hostPath = utf8String $ unwords [cmdPfx, chatNameStr cn, hostPath] + uploadFile = \case + SendFile cn ctrlPath -> fileCmd "/file" cn <$> storeRemoteFile ctrlClient ctrlPath + SendImage cn ctrlPath -> fileCmd "/image" cn <$> storeRemoteFile ctrlClient ctrlPath + -- TODO APISendMessage should only be used with host path already, and UI has to upload file first. + -- The problem is that we cannot have different file names in host and controller, because it simply won't be able to show files. + -- So we need to ask the host to store files BEFORE storing them in the app storage and use host names in the command and to store the file locally if it has to be shown, + -- or don't even store it if it's not image/video. + -- The current approach won't work. + -- It also does not account for local file encryption. + -- Also, local file encryption setting should be tracked in the controller, as otherwise host won't be able to decide what to do having received the upload command. + APISendMessage {composedMessage = cm@ComposedMessage {fileSource = Just CryptoFile {filePath = ctrlPath, cryptoArgs}}} -> do + hostPath <- storeRemoteFile ctrlClient ctrlPath + let cm' = cm {fileSource = Just CryptoFile {filePath = hostPath, cryptoArgs}} :: ComposedMessage + -- TODO we shouldn't manipulate JSON like that + pure $ B.takeWhile (/= '{') s <> B.toStrict (J.encode cm') + _ -> pure s + +relayCommand :: ChatMonad m => HTTP2Client -> ByteString -> m ChatResponse +relayCommand http s = do + -- TODO ExceptT + let timeout' = Nothing + HTTP2Response {respBody = HTTP2Body {bodyHead}} <- + liftHTTP2 $ HTTP2.sendRequestDirect http req timeout' + -- TODO: large JSONs can overflow into buffered chunks + json <- liftEitherWith (ChatErrorRemoteCtrl . RCEInvalidResponse) $ J.eitherDecodeStrict' bodyHead + case J.fromJSON $ toTaggedJSON json of + J.Error e -> err $ show e + J.Success cr -> pure cr + where + err = pure . CRChatError Nothing . ChatErrorRemoteCtrl . RCEInvalidResponse toTaggedJSON :: J.Value -> J.Value toTaggedJSON = id -- owsf2tagged TODO: get from RemoteHost - -- XXX: extract to http2 transport - postBytestring timeout' c path hs body = liftIO $ HTTP2.sendRequestDirect c req timeout' - where - req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body) + req = HC.requestBuilder "POST" "/send" mempty (Binary.fromByteString s) -handleRcvFileComplete :: (ChatMonad m) => HTTP2Client -> FilePath -> User -> CIFile 'MDRcv -> m (Maybe (CIFile 'MDRcv)) -handleRcvFileComplete http storePath remoteUser cif@CIFile {fileId, fileName, fileStatus} = case fileStatus of - CIFSRcvComplete -> - chatReadVar filesFolder >>= \case - Just baseDir -> do - let hostStore = baseDir storePath - createDirectoryIfMissing True hostStore - localPath <- uniqueCombine hostStore fileName - ok <- fetchRemoteFile http remoteUser fileId localPath - if ok - then pure $ Just (cif {fileName = localPath} :: CIFile 'MDRcv) - else Nothing <$ logError "fetchRemoteFile failed" - Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" - _ -> Nothing <$ logDebug ("Ingoring invalid file notification for file (" <> tshow fileId <> ") " <> tshow fileName) +-- TODO fileName is just metadata that does not determine the actual file location for UI, or whether it is encrypted or not +-- fileSource is the actual file location (with information whether it is locally encrypted) +handleRcvFileComplete :: ChatMonad m => HTTP2Client -> FilePath -> User -> CIFile 'MDRcv -> m (Maybe (CIFile 'MDRcv)) +handleRcvFileComplete http storePath remoteUser f@CIFile {fileId, fileName} = + chatReadVar filesFolder >>= \case + Just baseDir -> do + let hostStore = baseDir storePath + createDirectoryIfMissing True hostStore + -- TODO the problem here is that the name may turn out to be different and nothing will work + -- file processing seems to work "accidentally", not "by design" + localPath <- uniqueCombine hostStore fileName + fetchRemoteFile http remoteUser fileId localPath + pure $ Just (f {fileName = localPath} :: CIFile 'MDRcv) + -- TODO below will not work with CLI, it should store file to download folder when not specified + -- It should not load all files when received, instead it should only load files received with /fr commands + Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" -- | Convert swift single-field sum encoding into tagged/discriminator-field owsf2tagged :: J.Value -> J.Value @@ -288,36 +282,42 @@ owsf2tagged = fst . convert pattern OwsfTag :: (JK.Key, J.Value) pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) -storeRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> FilePath -> m (Maybe FilePath) +storeRemoteFile :: ChatMonad m => HTTP2Client -> FilePath -> m FilePath storeRemoteFile http localFile = do - putFile Nothing http uri mempty localFile >>= \case - Left h2ce -> Nothing <$ logError (tshow h2ce) - Right HTTP2.HTTP2Response {response, respBody = HTTP2Body {bodyHead}} -> - case HTTP.statusCode <$> HTTP2Client.responseStatus response of - Just 200 -> pure . Just $ B.unpack bodyHead - notOk -> Nothing <$ logError ("Bad response status: " <> tshow notOk) + fileSize <- liftIO $ fromIntegral <$> getFileSize localFile + -- TODO configure timeout + let timeout' = Nothing + r@HTTP2Response {respBody = HTTP2Body {bodyHead}} <- + liftHTTP2 $ HTTP2.sendRequestDirect http (req fileSize) timeout' + responseStatusOK r + -- TODO what if response doesn't fit in the head? + -- it'll be solved when processing moved to POST with Command/Response types + pure $ B.unpack bodyHead where + -- TODO local file encryption? uri = "/store?" <> HTTP.renderSimpleQuery False [("file_name", utf8String $ takeFileName localFile)] - putFile timeout' c path hs file = liftIO $ do - fileSize <- fromIntegral <$> getFileSize file - HTTP2.sendRequestDirect c (req fileSize) timeout' - where - req size = HTTP2Client.requestFile "PUT" path hs (HTTP2Client.FileSpec file 0 size) + req size = HC.requestFile "PUT" uri mempty (HC.FileSpec localFile 0 size) -fetchRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> User -> Int64 -> FilePath -> m Bool +liftHTTP2 :: ChatMonad m => IO (Either HTTP2ClientError a) -> m a +liftHTTP2 = liftEitherError $ ChatErrorRemoteCtrl . RCEHTTP2Error . show + +responseStatusOK :: ChatMonad m => HTTP2Response -> m () +responseStatusOK HTTP2Response {response} = do + let s = HC.responseStatus response + unless (s == Just Status.ok200) $ + throwError $ ChatErrorRemoteCtrl $ RCEHTTP2RespStatus $ Status.statusCode <$> s + +fetchRemoteFile :: ChatMonad m => HTTP2Client -> User -> Int64 -> FilePath -> m () fetchRemoteFile http User {userId = remoteUserId} remoteFileId localPath = do - liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case - Left h2ce -> False <$ logError (tshow h2ce) - Right HTTP2.HTTP2Response {response, respBody} -> - if HTTP2Client.responseStatus response == Just Status.ok200 - then True <$ writeBodyToFile localPath respBody - else False <$ (logError $ "Request failed: " <> maybe "(??)" tshow (HTTP2Client.responseStatus response) <> " " <> decodeUtf8 (bodyHead respBody)) + r@HTTP2Response {respBody} <- liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing + responseStatusOK r + writeBodyToFile localPath respBody where - req = HTTP2Client.requestNoBody "GET" path mempty + req = HC.requestNoBody "GET" path mempty path = "/fetch?" <> HTTP.renderSimpleQuery False [("user_id", bshow remoteUserId), ("file_id", bshow remoteFileId)] -- XXX: extract to Transport.HTTP2 ? -writeBodyToFile :: (MonadUnliftIO m) => FilePath -> HTTP2Body -> m () +writeBodyToFile :: MonadUnliftIO m => FilePath -> HTTP2Body -> m () writeBodyToFile path HTTP2Body {bodyHead, bodySize, bodyPart} = do logInfo $ "Receiving " <> tshow bodySize <> " bytes to " <> tshow path liftIO . withFile path WriteMode $ \h -> do @@ -331,7 +331,8 @@ hPutBodyChunks h getChunk = do hPut h chunk hPutBodyChunks h getChunk -processControllerRequest :: forall m. (ChatMonad m) => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () +-- TODO command/response pattern, remove REST conventions +processControllerRequest :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, sendResponse} = do logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) res <- tryChatError $ case (method, ps) of @@ -345,8 +346,8 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s Left e -> logError $ "Error handling remote controller request: (" <> tshow (method <> " " <> path) <> "): " <> tshow e Right () -> logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) <> " OK" where - method = fromMaybe "" $ HTTP2Server.requestMethod request - path = fromMaybe "/" $ HTTP2Server.requestPath request + method = fromMaybe "" $ HS.requestMethod request + path = fromMaybe "/" $ HS.requestPath request (ps, query) = HTTP.decodePath path getHello = respond "OK" sendCommand = execChatCommand (bodyHead reqBody) >>= respondJSON @@ -354,6 +355,7 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s chatReadVar remoteCtrlSession >>= \case Nothing -> respondWith Status.internalServerError500 "session not active" Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON + -- TODO liftEither storeFileQuery storeFile = case storeFileQuery of Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) Right fileName -> do @@ -365,6 +367,7 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s respond $ Binary.putStringUtf8 storeRelative where storeFileQuery = parseField "file_name" $ A.many1 (A.satisfy $ not . isPathSeparator) + -- TODO move to ExceptT monad, catch errors in one place, convert errors to responses fetchFile = case fetchFileQuery of Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) Right (userId, fileId) -> do @@ -372,12 +375,13 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s x <- withStore' $ \db -> runExceptT $ do user <- getUser db userId getRcvFileTransfer db user fileId + -- TODO this error handling is very ad-hoc, there is no separation between Chat errors and responses case x of Right RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> do baseDir <- fromMaybe "." <$> chatReadVar filesFolder let fullPath = baseDir filePath size <- fromInteger <$> getFileSize fullPath - liftIO . sendResponse . HTTP2Server.responseFile Status.ok200 mempty $ HTTP2Server.FileSpec fullPath 0 size + liftIO . sendResponse . HS.responseFile Status.ok200 mempty $ HS.FileSpec fullPath 0 size Right _ -> respondWith Status.internalServerError500 "The requested file is not complete" Left SEUserNotFound {} -> respondWith Status.notFound404 "User not found" Left SERcvFileNotFound {} -> respondWith Status.notFound404 "File not found" @@ -395,101 +399,106 @@ processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, s respondJSON = respond . Binary.fromLazyByteString . J.encode respond = respondWith Status.ok200 - respondWith status = liftIO . sendResponse . HTTP2Server.responseBuilder status [] + respondWith status = liftIO . sendResponse . HS.responseBuilder status [] -- * ChatRequest handlers -startRemoteCtrl :: (ChatMonad m) => (ByteString -> m ChatResponse) -> m () -startRemoteCtrl execChatCommand = - chatReadVar remoteCtrlSession >>= \case - Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy - Nothing -> do - size <- asks $ tbqSize . config - remoteOutputQ <- newTBQueueIO size - discovered <- newTVarIO mempty - discoverer <- async $ discoverRemoteCtrls discovered - accepted <- newEmptyTMVarIO - supervisor <- async $ do - remoteCtrlId <- atomically (readTMVar accepted) - withRemoteCtrl remoteCtrlId $ \rc@RemoteCtrl {fingerprint} -> do - source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure - toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False - atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) - chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} - toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True - _ <- waitCatch server - chatWriteVar remoteCtrlSession Nothing - toView CRRemoteCtrlStopped - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} - -discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m () -discoverRemoteCtrls discovered = Discovery.withListener go +startRemoteCtrl :: ChatMonad m => (ByteString -> m ChatResponse) -> m () +startRemoteCtrl execChatCommand = do + checkNoRemoteCtrlSession + size <- asks $ tbqSize . config + remoteOutputQ <- newTBQueueIO size + discovered <- newTVarIO mempty + discoverer <- async $ discoverRemoteCtrls discovered + accepted <- newEmptyTMVarIO + supervisor <- async $ runSupervisor discovered accepted + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} where - go sock = + runSupervisor discovered accepted = do + remoteCtrlId <- atomically (readTMVar accepted) + rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) + source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure + toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False + atomically $ writeTVar discovered mempty -- flush unused sources + server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) + chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} + toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True + _ <- waitCatch server + chatWriteVar remoteCtrlSession Nothing + toView CRRemoteCtrlStopped + +-- TODO the problem with this code was that it wasn't clear where the recursion can happen, +-- by splitting receiving and processing to two functions it becomes clear +discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> m () +discoverRemoteCtrls discovered = Discovery.withListener $ receive >=> process + where + -- TODO how would it receive more than one fingerprint? + receive sock = Discovery.recvAnnounce sock >>= \case (SockAddrInet _sockPort sockAddr, invite) -> case strDecode invite of - Left _ -> go sock -- ignore malformed datagrams - Right fingerprint -> do - let addr = THIPv4 (hostAddressToTuple sockAddr) - ifM - (atomically $ TM.member fingerprint discovered) - (logDebug $ "Fingerprint announce already knwon: " <> tshow (addr, invite)) - ( do - logInfo $ "New fingerprint announce: " <> tshow (addr, invite) - atomically $ TM.insert fingerprint addr discovered - ) - withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case - Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required - Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of - Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required - Just False -> pure () -- skipping a rejected item - Just True -> - chatReadVar remoteCtrlSession >>= \case - Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" - Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically - _nonV4 -> go sock + -- TODO it is probably better to report errors to view here + Left _ -> receive sock + Right fingerprint -> pure (sockAddr, fingerprint) + _nonV4 -> receive sock + process (sockAddr, fingerprint) = do + let addr = THIPv4 (hostAddressToTuple sockAddr) + ifM + (atomically $ TM.member fingerprint discovered) + (logDebug $ "Fingerprint already known: " <> tshow (addr, fingerprint)) + ( do + logInfo $ "New fingerprint announced: " <> tshow (addr, fingerprint) + atomically $ TM.insert fingerprint addr discovered + ) + -- TODO we check fingerprint for duplicate where id doesn't matter - to prevent re-insert - and don't check to prevent duplicate events, + -- so UI now will have to check for duplicates again + withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case + Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required + -- TODO Maybe Bool is very confusing - the intent is very unclear here + Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of + Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required + Just False -> pure () -- skipping a rejected item + Just True -> + chatReadVar remoteCtrlSession >>= \case + Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" + Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically -listRemoteCtrls :: (ChatMonad m) => m [RemoteCtrlInfo] +listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do active <- chatReadVar remoteCtrlSession $>>= \RemoteCtrlSession {accepted} -> atomically $ tryReadTMVar accepted map (rcInfo active) <$> withStore' getRemoteCtrls where - rcInfo active rc@RemoteCtrl {remoteCtrlId} = - remoteCtrlInfo rc $ active == Just remoteCtrlId + rcInfo activeRcId rc@RemoteCtrl {remoteCtrlId} = + remoteCtrlInfo rc $ activeRcId == Just remoteCtrlId remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo remoteCtrlInfo RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} sessionActive = RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted, sessionActive} -acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () -acceptRemoteCtrl remoteCtrlId = do - withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId True - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {accepted} -> atomically . void $ tryPutTMVar accepted remoteCtrlId -- the remote host can now proceed with connection +acceptRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () +acceptRemoteCtrl rcId = do + -- TODO check it exists, check the ID is the same as in session + RemoteCtrlSession {accepted} <- getRemoteCtrlSession + withStore' $ \db -> markRemoteCtrlResolution db rcId True + atomically . void $ tryPutTMVar accepted rcId -- the remote host can now proceed with connection -rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () -rejectRemoteCtrl remoteCtrlId = do - withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId False - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just RemoteCtrlSession {discoverer, supervisor} -> do - cancel discoverer - cancel supervisor +rejectRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () +rejectRemoteCtrl rcId = do + withStore' $ \db -> markRemoteCtrlResolution db rcId False + RemoteCtrlSession {discoverer, supervisor} <- getRemoteCtrlSession + cancel discoverer + cancel supervisor -stopRemoteCtrl :: (ChatMonad m) => m () -stopRemoteCtrl = - chatReadVar remoteCtrlSession >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive - Just rcs -> cancelRemoteCtrlSession rcs $ chatWriteVar remoteCtrlSession Nothing +stopRemoteCtrl :: ChatMonad m => m () +stopRemoteCtrl = do + rcs <- getRemoteCtrlSession + cancelRemoteCtrlSession rcs $ chatWriteVar remoteCtrlSession Nothing -cancelRemoteCtrlSession_ :: (MonadUnliftIO m) => RemoteCtrlSession -> m () +cancelRemoteCtrlSession_ :: MonadUnliftIO m => RemoteCtrlSession -> m () cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure () -cancelRemoteCtrlSession :: (MonadUnliftIO m) => RemoteCtrlSession -> m () -> m () +cancelRemoteCtrlSession :: MonadUnliftIO m => RemoteCtrlSession -> m () -> m () cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} cleanup = do cancel discoverer -- may be gone by now case hostServer of @@ -498,17 +507,19 @@ cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} c cancel supervisor -- supervisor is blocked until session progresses cleanup -deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m () -deleteRemoteCtrl remoteCtrlId = - chatReadVar remoteCtrlSession >>= \case - Nothing -> withStore' $ \db -> deleteRemoteCtrlRecord db remoteCtrlId - Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy +deleteRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () +deleteRemoteCtrl rcId = do + checkNoRemoteCtrlSession + -- TODO check it exists + withStore' (`deleteRemoteCtrlRecord` rcId) -withRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrl -> m a) -> m a -withRemoteCtrl remoteCtrlId action = - withStore' (`getRemoteCtrl` remoteCtrlId) >>= \case - Nothing -> throwError $ ChatErrorRemoteCtrl RCEMissing {remoteCtrlId} - Just rc -> action rc +getRemoteCtrlSession :: ChatMonad m => m RemoteCtrlSession +getRemoteCtrlSession = + chatReadVar remoteCtrlSession >>= maybe (throwError $ ChatErrorRemoteCtrl RCEInactive) pure + +checkNoRemoteCtrlSession :: ChatMonad m => m () +checkNoRemoteCtrlSession = + chatReadVar remoteCtrlSession >>= maybe (pure ()) (\_ -> throwError $ ChatErrorRemoteCtrl RCEBusy) utf8String :: [Char] -> ByteString utf8String = encodeUtf8 . T.pack diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index 40314b4cb5..01c6d12c6e 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -53,8 +53,8 @@ pattern BROADCAST_PORT = "5226" -- | Announce tls server, wait for connection and attach http2 client to it. -- -- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: (StrEncoding invite, MonadUnliftIO m) => m () -> invite -> TLS.Credentials -> m (Either HTTP2ClientError HTTP2Client) -announceRevHTTP2 finishAction invite credentials = do +announceRevHTTP2 :: StrEncoding a => a -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 invite credentials finishAction = do httpClient <- newEmptyMVar started <- newEmptyTMVarIO finished <- newEmptyMVar @@ -77,6 +77,8 @@ runAnnouncer inviteBS = do UDP.send sock inviteBS threadDelay 1000000 +-- TODO what prevents second client from connecting to the same server? +-- Do we need to start multiple TLS servers for different mobile hosts? startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig where diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 9189a27769..a4c2ef85e1 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -4,14 +4,15 @@ module Simplex.Chat.Store.Remote where +import Control.Monad.Except import Data.Int (Int64) import Data.Text (Text) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.Store.Shared import Simplex.Chat.Remote.Types -import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) +import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Crypto as C insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId @@ -23,9 +24,9 @@ getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts db = map toRemoteHost <$> DB.query_ db remoteHostQuery -getRemoteHost :: DB.Connection -> RemoteHostId -> IO (Maybe RemoteHost) +getRemoteHost :: DB.Connection -> RemoteHostId -> ExceptT StoreError IO RemoteHost getRemoteHost db remoteHostId = - maybeFirstRow toRemoteHost $ + ExceptT . firstRow toRemoteHost (SERemoteHostNotFound remoteHostId) $ DB.query db (remoteHostQuery <> " WHERE remote_host_id = ?") (Only remoteHostId) remoteHostQuery :: SQL.Query @@ -48,9 +49,9 @@ getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] getRemoteCtrls db = map toRemoteCtrl <$> DB.query_ db remoteCtrlQuery -getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> IO (Maybe RemoteCtrl) +getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> ExceptT StoreError IO RemoteCtrl getRemoteCtrl db remoteCtrlId = - maybeFirstRow toRemoteCtrl $ + ExceptT . firstRow toRemoteCtrl (SERemoteCtrlNotFound remoteCtrlId) $ DB.query db (remoteCtrlQuery <> " WHERE remote_controller_id = ?") (Only remoteCtrlId) getRemoteCtrlByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteCtrl) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index fabe5b9962..5cc1e87d58 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -31,6 +31,7 @@ import Database.SQLite.Simple.QQ (sql) import GHC.Generics (Generic) import Simplex.Chat.Messages import Simplex.Chat.Protocol +import Simplex.Chat.Remote.Types import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) @@ -100,6 +101,8 @@ data StoreError | SEHostMemberIdNotFound {groupId :: Int64} | SEContactNotFoundByFileId {fileId :: FileTransferId} | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} + | SERemoteHostNotFound {remoteHostId :: RemoteHostId} + | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} deriving (Show, Exception, Generic) instance FromJSON StoreError where diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 5bc1845803..b739c19882 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -33,7 +33,7 @@ import UnliftIO import UnliftIO.Directory remoteTests :: SpecWith FilePath -remoteTests = describe "Handshake" $ do +remoteTests = fdescribe "Handshake" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test it "connects desktop and mobile" remoteHandshakeTest @@ -70,7 +70,7 @@ announceDiscoverHttp2Test _tmp = do controller <- async $ do traceM " - Controller: starting" bracket - (Discovery.announceRevHTTP2 (putMVar finished ()) fingerprint credentials >>= either (fail . show) pure) + (Discovery.announceRevHTTP2 fingerprint credentials (putMVar finished ()) >>= either (fail . show) pure) closeHTTP2Client ( \http -> do traceM " - Controller: got client" From 838751fe782fdd72fe4d88267b6b75b903aff27a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 18:12:13 +0100 Subject: [PATCH 080/294] ios: fix Protect screen not hiding message previews (#3226) --- apps/ios/Shared/Views/ChatList/ChatPreviewView.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 3ac8fada74..1e153baf44 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -105,14 +105,17 @@ struct ChatPreviewView: View { private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View { ZStack(alignment: .topTrailing) { - text + let t = text .lineLimit(2) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, 8) .padding(.trailing, 36) - .privacySensitive(!showChatPreviews && !draft) - .redacted(reason: .privacy) + if !showChatPreviews && !draft { + t.privacySensitive(true).redacted(reason: .privacy) + } else { + t + } let s = chat.chatStats if s.unreadCount > 0 || s.unreadChat { unreadCountText(s.unreadCount) From c2a320640b224dad03e05f72a1ecb836cb6fefbc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 18:16:12 +0100 Subject: [PATCH 081/294] core: local encryption for auto-received inline files (e.g. small voice messages) (#3224) * core: local encryption for auto-received inline files * update view, test --- apps/ios/Shared/Model/SimpleXAPI.swift | 7 +++ .../ios/SimpleX NSE/NotificationService.swift | 7 +++ apps/ios/SimpleXChat/APITypes.swift | 23 +++++---- .../chat/simplex/common/model/SimpleXAPI.kt | 20 +++++++- src/Simplex/Chat.hs | 44 ++++++++++------- src/Simplex/Chat/Controller.hs | 6 ++- src/Simplex/Chat/View.hs | 49 ++++++++++--------- tests/ChatTests/Files.hs | 33 ++++++++++++- 8 files changed, 133 insertions(+), 56 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a1c8cee774..91c5b1b36d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -257,6 +257,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } +func apiSetEncryptLocalFiles(_ enable: Boolean) throws { + let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) + if case .cmdOk = r { return } + throw r +} + func apiExportArchive(config: ArchiveConfig) async throws { try await sendCommandOkResp(.apiExportArchive(config: config)) } @@ -1152,6 +1158,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(getXFTPCfg()) + // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 645fdb5952..2fb069a86c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -216,6 +216,7 @@ func startChat() -> DBMigrationResult? { try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(xftpConfig) + // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) let justStarted = try apiStartChat() chatStarted = true if justStarted { @@ -351,6 +352,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } +func apiSetEncryptLocalFiles(_ enable: Boolean) throws { + let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) + if case .cmdOk = r { return } + throw r +} + func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { guard apiGetActiveUser() != nil else { logger.debug("no active user") diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 9eb9b9084c..ddab4c73bd 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -32,6 +32,7 @@ public enum ChatCommand { case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) case apiSetXFTPConfig(config: XFTPFileConfig?) + case apiSetEncryptLocalFiles(enable: Bool) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage @@ -114,8 +115,8 @@ public enum ChatCommand { case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) - case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) - case setFileToReceive(fileId: Int64, encrypted: Bool) + case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?) + case setFileToReceive(fileId: Int64, encrypted: Bool?) case cancelFile(fileId: Int64) case showVersion case string(String) @@ -152,6 +153,7 @@ public enum ChatCommand { } else { return "/_xftp off" } + case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" @@ -247,13 +249,8 @@ public enum ChatCommand { case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" - case let .receiveFile(fileId, encrypted, inline): - let s = "/freceive \(fileId) encrypt=\(onOff(encrypted))" - if let inline = inline { - return s + " inline=\(onOff(inline))" - } - return s - case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))" + case let .receiveFile(fileId, encrypt, inline): return "/freceive \(fileId)\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" + case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))" case let .cancelFile(fileId): return "/fcancel \(fileId)" case .showVersion: return "/version" case let .string(str): return str @@ -283,6 +280,7 @@ public enum ChatCommand { case .setTempFolder: return "setTempFolder" case .setFilesFolder: return "setFilesFolder" case .apiSetXFTPConfig: return "apiSetXFTPConfig" + case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" @@ -420,6 +418,13 @@ public enum ChatCommand { b ? "on" : "off" } + private func onOffParam(_ param: String, _ b: Bool?) -> String { + if let b = b { + return " \(param)=\(onOff(b))" + } + return "" + } + private func maybePwd(_ pwd: String?) -> String { pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index e19520093c..f49984b942 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -338,6 +338,7 @@ object ChatController { apiSetTempFolder(coreTmpDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath) apiSetXFTPConfig(getXFTPCfg()) +// apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() val users = listUsers() chatModel.users.clear() @@ -559,6 +560,8 @@ object ChatController { throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}") } + suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiExportArchive(config: ArchiveConfig) { val r = sendCmd(CC.ApiExportArchive(config)) if (r is CR.CmdOk) return @@ -1327,6 +1330,13 @@ object ChatController { } } + private suspend fun sendCommandOkResp(cmd: CC): Boolean { + val r = sendCmd(cmd) + val ok = r is CR.CmdOk + if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error), r) + return ok + } + suspend fun apiGetVersion(): CoreVersionInfo? { val r = sendCmd(CC.ShowVersion()) return if (r is CR.VersionInfo) { @@ -1858,6 +1868,7 @@ sealed class CC { class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() + class ApiSetEncryptLocalFiles(val enable: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() @@ -1931,7 +1942,7 @@ sealed class CC { class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() - class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() + class ReceiveFile(val fileId: Long, val encrypt: Boolean?, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() class ShowVersion(): CC() @@ -1963,6 +1974,7 @@ sealed class CC { is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" + is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" @@ -2039,7 +2051,10 @@ sealed class CC { is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" - is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") + is ReceiveFile -> + "/freceive $fileId" + + (if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") + + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" is ShowVersion -> "/version" } @@ -2063,6 +2078,7 @@ sealed class CC { is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" is ApiSetXFTPConfig -> "apiSetXFTPConfig" + is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 56077c3bf6..90a6617059 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -209,6 +209,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen cleanupManagerAsync <- newTVarIO Nothing timedItemThreads <- atomically TM.empty showLiveItems <- newTVarIO False + encryptLocalFiles <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir contactMergeEnabled <- newTVarIO True @@ -236,6 +237,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen cleanupManagerAsync, timedItemThreads, showLiveItems, + encryptLocalFiles, userXFTPFileConfig, tempDirectory, logFilePath = logFile, @@ -515,6 +517,7 @@ processChatCommand = \case APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ + APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ SetContactMergeEnabled onOff -> do asks contactMergeEnabled >>= atomically . (`writeTVar` onOff) ok_ @@ -1773,19 +1776,16 @@ processChatCommand = \case ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" - ReceiveFile fileId encrypted rcvInline_ filePath_ -> withUser $ \_ -> + ReceiveFile fileId encrypted_ rcvInline_ filePath_ -> withUser $ \_ -> withChatLock "receiveFile" . procCmd $ do (user, ft) <- withStore (`getRcvFileTransferById` fileId) - ft' <- if encrypted then encryptLocalFile ft else pure ft + encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles + ft' <- (if encrypt then setFileToEncrypt else pure) ft receiveFile' user ft' rcvInline_ filePath_ - where - encryptLocalFile ft = do - cfArgs <- liftIO $ CF.randomArgs - withStore' $ \db -> setFileCryptoArgs db fileId cfArgs - pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs} - SetFileToReceive fileId encrypted -> withUser $ \_ -> do + SetFileToReceive fileId encrypted_ -> withUser $ \_ -> do withChatLock "setFileToReceive" . procCmd $ do - cfArgs <- if encrypted then Just <$> liftIO CF.randomArgs else pure Nothing + encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles + cfArgs <- if encrypt then Just <$> liftIO CF.randomArgs else pure Nothing withStore' $ \db -> setRcvFileToReceive db fileId cfArgs ok_ CancelFile fileId -> withUser $ \user@User {userId} -> @@ -2410,6 +2410,12 @@ toFSFilePath :: ChatMonad' m => FilePath -> m FilePath toFSFilePath f = maybe f ( f) <$> (readTVarIO =<< asks filesFolder) +setFileToEncrypt :: ChatMonad m => RcvFileTransfer -> m RcvFileTransfer +setFileToEncrypt ft@RcvFileTransfer {fileId} = do + cfArgs <- liftIO CF.randomArgs + withStore' $ \db -> setFileCryptoArgs db fileId cfArgs + pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs} + receiveFile' :: ChatMonad m => User -> RcvFileTransfer -> Maybe Bool -> Maybe FilePath -> m ChatResponse receiveFile' user ft rcvInline_ filePath_ = do (CRRcvFileAccepted user <$> acceptFileReceive user ft rcvInline_ filePath_) `catchChatError` processError @@ -3931,14 +3937,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do inline <- receiveInlineMode fInv (Just mc) fileChunkSize ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP - (filePath, fileStatus) <- case inline of + (filePath, fileStatus, ft') <- case inline of Just IFMSent -> do + encrypt <- chatReadVar encryptLocalFiles + ft' <- (if encrypt then setFileToEncrypt else pure) ft fPath <- getRcvFilePath fileId Nothing fileName True - withStore' $ \db -> startRcvInlineFT db user ft fPath inline - pure (Just fPath, CIFSRcvAccepted) - _ -> pure (Nothing, CIFSRcvInvitation) - let fileSource = CF.plain <$> filePath - pure (ft, CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) + withStore' $ \db -> startRcvInlineFT db user ft' fPath inline + pure (Just fPath, CIFSRcvAccepted, ft') + _ -> pure (Nothing, CIFSRcvInvitation, ft) + let RcvFileTransfer {cryptoArgs} = ft' + fileSource = (`CryptoFile` cryptoArgs) <$> filePath + pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do @@ -5567,6 +5576,7 @@ chatCommandP = ("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath), "/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))), "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))), + "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, @@ -5743,8 +5753,8 @@ chatCommandP = ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal), ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal), ("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath), - ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), - "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False)), + ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), + "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> optional (" encrypt=" *> onOffP)), ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal), ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), "/simplex" *> (ConnectSimplex <$> incognitoP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2704c2721f..0a9b886940 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -179,6 +179,7 @@ data ChatController = ChatController cleanupManagerAsync :: TVar (Maybe (Async ())), timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))), showLiveItems :: TVar Bool, + encryptLocalFiles :: TVar Bool, userXFTPFileConfig :: TVar (Maybe XFTPFileConfig), tempDirectory :: TVar (Maybe FilePath), logFilePath :: Maybe FilePath, @@ -221,6 +222,7 @@ data ChatCommand | SetTempFolder FilePath | SetFilesFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) + | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool | APIExportArchive ArchiveConfig | ExportArchive @@ -393,8 +395,8 @@ data ChatCommand | ForwardFile ChatName FileTransferId | ForwardImage ChatName FileTransferId | SendFileDescription ChatName FilePath - | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath} - | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Bool} + | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Maybe Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath} + | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Maybe Bool} | CancelFile FileTransferId | FileStatus FileTransferId | ShowProfile -- UserId (not used in UI) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index bcbc45f4eb..08b1579bde 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -166,7 +166,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRRcvFileDescrReady _ _ -> [] CRRcvFileDescrNotReady _ _ -> [] CRRcvFileProgressXFTP {} -> [] - CRRcvFileAccepted u ci -> ttyUser u $ savingFile' testView ci + CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts CRRcvFileCancelled u _ ft -> ttyUser u $ receivingFile_ "cancelled" ft @@ -178,10 +178,10 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile - CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci - CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci + CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci + CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' testView "completed" ci CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRRcvFileError u ci e -> ttyUser u $ receivingFile_' "error" ci <> [sShow e] + CRRcvFileError u ci e -> ttyUser u $ receivingFile_' testView "error" ci <> [sShow e] CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndFileStartXFTP {} -> [] @@ -1449,27 +1449,28 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 -savingFile' :: Bool -> AChatItem -> [StyledString] -savingFile' testView (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath cfArgs_)}, chatDir}) = - let from = case (chat, chatDir) of - (DirectChat Contact {localDisplayName = c}, CIDirectRcv) -> " from " <> ttyContact c - (_, CIGroupRcv GroupMember {localDisplayName = m}) -> " from " <> ttyContact m - _ -> "" - in ["saving file " <> sShow fileId <> from <> " to " <> plain filePath] <> cfArgsStr - where - cfArgsStr = case cfArgs_ of - Just cfArgs@(CFArgs key nonce) - | testView -> [plain $ LB.unpack $ J.encode cfArgs] - | otherwise -> [plain $ "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce] - _ -> [] -savingFile' _ _ = ["saving file"] -- shouldn't happen +savingFile' :: AChatItem -> [StyledString] +savingFile' (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath _)}, chatDir}) = + ["saving file " <> sShow fileId <> fileFrom chat chatDir <> " to " <> plain filePath] +savingFile' _ = ["saving file"] -- shouldn't happen -receivingFile_' :: StyledString -> AChatItem -> [StyledString] -receivingFile_' status (AChatItem _ _ (DirectChat c) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = - [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact' c] -receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv m}) = - [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyMember m] -receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen +receivingFile_' :: Bool -> String -> AChatItem -> [StyledString] +receivingFile_' testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just (CryptoFile _ cfArgs_)}, chatDir}) = + [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ + where + cfArgsStr (Just cfArgs@(CFArgs key nonce)) = [plain s | status == "completed"] + where + s = + if testView + then LB.toStrict $ J.encode cfArgs + else "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce + cfArgsStr _ = [] +receivingFile_' _ status _ = [plain status <> " receiving file"] -- shouldn't happen + +fileFrom :: ChatInfo c -> CIDirection c d -> StyledString +fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct +fileFrom _ (CIGroupRcv m) = " from " <> ttyMember m +fileFrom _ _ = "" receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} = diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 50f86d8e09..5231283903 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -33,6 +33,7 @@ chatFileTests = do describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer describe "send file, receive and locally encrypt file" $ fileTestMatrix2 runTestFileTransferEncrypted it "send and receive file inline (without accepting)" testInlineFileTransfer + it "send inline file, receive (without accepting) and locally encrypt" testInlineFileTransferEncrypted xit'' "accept inline file transfer, sender cancels during transfer" testAcceptInlineFileSndCancelDuringTransfer it "send and receive small file inline (default config)" testSmallInlineFileTransfer it "small file sent without acceptance is ignored in terminal by default" testSmallInlineFileIgnored @@ -107,7 +108,6 @@ runTestFileTransferEncrypted alice bob = do bob <## "use /fr 1 [
/ | ] to receive it" bob ##> "/fr 1 encrypt=on ./tests/tmp" bob <## "saving file 1 from alice to ./tests/tmp/test.pdf" - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob concurrently_ (bob <## "started receiving file 1 (test.pdf) from alice") (alice <## "started sending file 1 (test.pdf) to bob") @@ -123,6 +123,7 @@ runTestFileTransferEncrypted alice bob = do "completed sending file 1 (test.pdf) to bob" ] ] + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob src <- B.readFile "./tests/fixtures/test.pdf" -- dest <- B.readFile "./tests/tmp/test.pdf" -- dest `shouldBe` src @@ -154,6 +155,34 @@ testInlineFileTransfer = where cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} +testInlineFileTransferEncrypted :: HasCallStack => FilePath -> IO () +testInlineFileTransferEncrypted = + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + bob ##> "/_files_folder ./tests/tmp/" + bob <## "ok" + bob ##> "/_files_encrypt on" + bob <## "ok" + alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}" + alice <# "@bob voice message (00:10)" + alice <# "/f @bob ./tests/fixtures/test.jpg" + -- below is not shown in "sent" mode + -- alice <## "use /fc 1 to cancel sending" + bob <# "alice> voice message (00:10)" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + -- below is not shown in "sent" mode + -- bob <## "use /fr 1 [/ | ] to receive it" + bob <## "started receiving file 1 (test.jpg) from alice" + concurrently_ + (alice <## "completed sending file 1 (test.jpg) to bob") + (bob <## "completed receiving file 1 (test.jpg) from alice") + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob + src <- B.readFile "./tests/fixtures/test.jpg" + Right dest <- chatReadFile "./tests/tmp/test.jpg" (strEncode key) (strEncode nonce) + LB.toStrict dest `shouldBe` src + where + cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} + testAcceptInlineFileSndCancelDuringTransfer :: HasCallStack => FilePath -> IO () testAcceptInlineFileSndCancelDuringTransfer = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -1077,10 +1106,10 @@ testXFTPFileTransferEncrypted = bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 encrypt=on ./tests/tmp/bob/" bob <## "saving file 1 from alice to ./tests/tmp/bob/test.pdf" - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob alice <## "completed uploading file 1 (test.pdf) for bob" bob <## "started receiving file 1 (test.pdf) from alice" bob <## "completed receiving file 1 (test.pdf) from alice" + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob Right dest <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) LB.length dest `shouldBe` fromIntegral srcLen LB.toStrict dest `shouldBe` src From 43b67ba157dab55d24d200a62aba42c580499d6e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 20:58:39 +0100 Subject: [PATCH 082/294] ui: set local file encryption in the core (#3227) --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 ++-- .../Shared/Views/UserSettings/PrivacySettings.swift | 13 +++++++++++++ apps/ios/SimpleX NSE/NotificationService.swift | 6 +++--- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 6 +++--- .../common/views/usersettings/PrivacySettings.kt | 4 +++- .../src/commonMain/resources/MR/base/strings.xml | 1 + 6 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 91c5b1b36d..2e724bcd83 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -257,7 +257,7 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } -func apiSetEncryptLocalFiles(_ enable: Boolean) throws { +func apiSetEncryptLocalFiles(_ enable: Bool) throws { let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } throw r @@ -1158,7 +1158,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(getXFTPCfg()) - // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) + try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 34b6f147bd..16555fad3a 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -66,6 +66,9 @@ struct PrivacySettings: View { Section { settingsRow("lock.doc") { Toggle("Encrypt local files", isOn: $encryptLocalFiles) + .onChange(of: encryptLocalFiles) { + setEncryptLocalFiles($0) + } } settingsRow("photo") { Toggle("Auto-accept images", isOn: $autoAcceptImages) @@ -183,6 +186,16 @@ struct PrivacySettings: View { } } + private func setEncryptLocalFiles(_ enable: Bool) { + do { + try apiSetEncryptLocalFiles(enable) + } catch let error { + let err = responseError(error) + logger.error("apiSetEncryptLocalFiles \(err)") + alert = .error(title: "Error", error: "\(err)") + } + } + private func setOrAskSendReceiptsContacts(_ enable: Bool) { contactReceiptsOverrides = m.chats.reduce(0) { count, chat in let sendRcpts = chat.chatInfo.contact?.chatSettings.sendRcpts diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 2fb069a86c..ea52f4be89 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -216,7 +216,7 @@ func startChat() -> DBMigrationResult? { try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(xftpConfig) - // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) + try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) let justStarted = try apiStartChat() chatStarted = true if justStarted { @@ -352,8 +352,8 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } -func apiSetEncryptLocalFiles(_ enable: Boolean) throws { - let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) +func apiSetEncryptLocalFiles(_ enable: Bool) throws { + let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } throw r } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index f49984b942..cc8d481b59 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -338,7 +338,7 @@ object ChatController { apiSetTempFolder(coreTmpDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath) apiSetXFTPConfig(getXFTPCfg()) -// apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) + apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() val users = listUsers() chatModel.users.clear() @@ -1333,7 +1333,7 @@ object ChatController { private suspend fun sendCommandOkResp(cmd: CC): Boolean { val r = sendCmd(cmd) val ok = r is CR.CmdOk - if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error), r) + if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) return ok } @@ -1942,7 +1942,7 @@ sealed class CC { class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() - class ReceiveFile(val fileId: Long, val encrypt: Boolean?, val inline: Boolean?): CC() + class ReceiveFile(val fileId: Long, val encrypt: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() class ShowVersion(): CC() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index ef0940b2a0..210d3c6136 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -64,7 +64,9 @@ fun PrivacySettingsView( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_chats)) { - SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles) + SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles, onChange = { enable -> + withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) } + }) SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) SettingsPreferenceItem( diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 7912a5ad1b..114fe49e92 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -113,6 +113,7 @@ Server requires authorization to upload, check password Possibly, certificate fingerprint in server address is incorrect Error setting address + Error Connect Disconnect Create queue From 4b6df43e97ba7ac125529c409f254c4cddb8cdda Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:10:56 +0400 Subject: [PATCH 083/294] core: confirm to reconnect via address plan (#3212) * core: confirm to reconnect plan * rework query to prefer connections with contacts --- src/Simplex/Chat.hs | 25 +++++++++++++++---------- src/Simplex/Chat/Controller.hs | 12 ++++++++---- src/Simplex/Chat/Store/Connections.hs | 24 ++++++++++++++++++++++++ src/Simplex/Chat/Types.hs | 3 +++ src/Simplex/Chat/View.hs | 8 +++++--- tests/ChatTests/Groups.hs | 7 +++++++ tests/ChatTests/Profiles.hs | 5 +++++ 7 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 90a6617059..ef56e42056 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1364,13 +1364,13 @@ processChatCommand = \case APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) - unless (connectionPlanOk plan) $ throwChatError (CEConnectionPlan plan) + unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) processChatCommand $ APIConnect userId incognito aCReqUri Connect _ Nothing -> throwChatError CEInvalidConnReq ConnectSimplex incognito -> withUser $ \user@User {userId} -> do let cReqUri = ACR SCMContact adminContactReq plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) - unless (connectionPlanOk plan) $ throwChatError (CEConnectionPlan plan) + unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) processChatCommand $ APIConnect userId incognito (Just cReqUri) DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect @@ -2245,27 +2245,32 @@ processChatCommand = \case Just _ -> pure $ CPContactAddress CAPOwnLink Nothing -> do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - withStore' (\db -> getContactByConnReqHash db user cReqHash) >>= \case + withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHash) >>= \case Nothing -> pure $ CPContactAddress CAPOk - Just ct - | not (contactReady ct) && contactActive ct -> pure $ CPContactAddress (CAPConnecting ct) + Just (RcvDirectMsgConnection _conn Nothing) -> pure $ CPContactAddress CAPConnectingConfirmReconnect + Just (RcvDirectMsgConnection _ (Just ct)) + | not (contactReady ct) && contactActive ct -> pure $ CPContactAddress (CAPConnectingProhibit ct) + | contactDeleted ct -> pure $ CPContactAddress CAPOk | otherwise -> pure $ CPContactAddress (CAPKnown ct) + Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" -- group link Just _ -> withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReq) >>= \case Just g -> pure $ CPGroupLink (GLPOwnLink g) Nothing -> do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - ct_ <- withStore' $ \db -> getContactByConnReqHash db user cReqHash + connEnt_ <- withStore' $ \db -> getContactConnEntityByConnReqHash db user cReqHash gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHash - case (gInfo_, ct_) of + case (gInfo_, connEnt_) of (Nothing, Nothing) -> pure $ CPGroupLink GLPOk - (Nothing, Just ct) - | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnecting gInfo_) + (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect + (Nothing, Just (RcvDirectMsgConnection _ (Just ct))) + | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnectingProhibit gInfo_) | otherwise -> pure $ CPGroupLink GLPOk + (Nothing, Just _) -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" (Just gInfo@GroupInfo {membership}, _) | not (memberActive membership) && not (memberRemoved membership) -> - pure $ CPGroupLink (GLPConnecting gInfo_) + pure $ CPGroupLink (GLPConnectingProhibit gInfo_) | memberActive membership -> pure $ CPGroupLink (GLPKnown gInfo) | otherwise -> pure $ CPGroupLink GLPOk diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0a9b886940..d8851ad87a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -647,7 +647,8 @@ instance ToJSON InvitationLinkPlan where data ContactAddressPlan = CAPOk | CAPOwnLink - | CAPConnecting {contact :: Contact} + | CAPConnectingConfirmReconnect + | CAPConnectingProhibit {contact :: Contact} | CAPKnown {contact :: Contact} deriving (Show, Generic) @@ -658,7 +659,8 @@ instance ToJSON ContactAddressPlan where data GroupLinkPlan = GLPOk | GLPOwnLink {groupInfo :: GroupInfo} - | GLPConnecting {groupInfo_ :: Maybe GroupInfo} + | GLPConnectingConfirmReconnect + | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} | GLPKnown {groupInfo :: GroupInfo} deriving (Show, Generic) @@ -666,8 +668,8 @@ instance ToJSON GroupLinkPlan where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "GLP" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "GLP" -connectionPlanOk :: ConnectionPlan -> Bool -connectionPlanOk = \case +connectionPlanProceed :: ConnectionPlan -> Bool +connectionPlanProceed = \case CPInvitationLink ilp -> case ilp of ILPOk -> True ILPOwnLink -> True @@ -675,10 +677,12 @@ connectionPlanOk = \case CPContactAddress cap -> case cap of CAPOk -> True CAPOwnLink -> True + CAPConnectingConfirmReconnect -> True _ -> False CPGroupLink glp -> case glp of GLPOk -> True GLPOwnLink _ -> True + GLPConnectingConfirmReconnect -> True _ -> False newtype UserPwd = UserPwd {unUserPwd :: Text} diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 3ef77cbb65..d8ec38269f 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -10,6 +10,7 @@ module Simplex.Chat.Store.Connections ( getConnectionEntity, getConnectionEntityByConnReq, + getContactConnEntityByConnReqHash, getConnectionsToSubscribe, unsetConnectionToSubscribe, ) @@ -159,6 +160,29 @@ getConnectionEntityByConnReq db user cReq = do DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv = ? LIMIT 1" (Only cReq) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ +-- search connection for connection plan: +-- multiple connections can have same via_contact_uri_hash if request was repeated; +-- this function searches for latest connection with contact so that "known contact" plan would be chosen; +-- deleted connections are filtered out to allow re-connecting via same contact address +getContactConnEntityByConnReqHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash db user cReqHash = do + connId_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT agent_conn_id FROM ( + SELECT + agent_conn_id, + (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord + FROM connections + WHERE via_contact_uri_hash = ? AND conn_status != ? + ORDER BY conn_ord DESC, created_at DESC + LIMIT 1 + ) + |] + (cReqHash, ConnDeleted) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ + getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) getConnectionsToSubscribe db = do aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 9992f96ab0..10b0c91d04 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -219,6 +219,9 @@ contactReady Contact {activeConn} = connReady activeConn contactActive :: Contact -> Bool contactActive Contact {contactStatus} = contactStatus == CSActive +contactDeleted :: Contact -> Bool +contactDeleted Contact {contactStatus} = contactStatus == CSDeleted + contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode activeConn diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 08b1579bde..fa2213d083 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1281,7 +1281,8 @@ viewConnectionPlan = \case CPContactAddress cap -> case cap of CAPOk -> [ctAddr "ok to connect"] CAPOwnLink -> [ctAddr "own address"] - CAPConnecting ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] + CAPConnectingConfirmReconnect -> [ctAddr "connecting, allowed to reconnect"] + CAPConnectingProhibit ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] CAPKnown ct -> [ ctAddr ("known contact " <> ttyContact' ct), "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" @@ -1291,8 +1292,9 @@ viewConnectionPlan = \case CPGroupLink glp -> case glp of GLPOk -> [grpLink "ok to connect"] GLPOwnLink g -> [grpLink "own link for group " <> ttyGroup' g] - GLPConnecting Nothing -> [grpLink "connecting"] - GLPConnecting (Just g) -> [grpLink ("connecting to group " <> ttyGroup' g)] + GLPConnectingConfirmReconnect -> [grpLink "connecting, allowed to reconnect"] + GLPConnectingProhibit Nothing -> [grpLink "connecting"] + GLPConnectingProhibit (Just g) -> [grpLink ("connecting to group " <> ttyGroup' g)] GLPKnown g -> [ grpLink ("known group " <> ttyGroup' g), "use " <> ttyToGroup g <> highlight' "" <> " to send messages" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 4c31805265..43498711e7 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2397,8 +2397,15 @@ testPlanGroupLinkConnecting tmp = do alice ##> "/create link #team" getGroupLink alice "team" GRMember True withNewTestChat tmp "bob" bobProfile $ \bob -> do + threadDelay 100000 + bob ##> ("/c " <> gLink) bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting, allowed to reconnect" + + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice <### [ "1 group links active", diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 80e1709222..5682d09ee8 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -651,8 +651,13 @@ testPlanAddressConnecting tmp = do getContactLink alice True withNewTestChat tmp "bob" bobProfile $ \bob -> do threadDelay 100000 + bob ##> ("/c " <> cLink) bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: connecting, allowed to reconnect" + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" From 9ed31261e1d4e7d4e6aba449fa80fa5bf0ffbd46 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:16:03 +0400 Subject: [PATCH 084/294] core: check saved links and hashes by both connection request uri schemas for connection plan (#3233) --- src/Simplex/Chat.hs | 31 ++++++++++++++++++------- src/Simplex/Chat/Store/Connections.hs | 14 ++++++------ src/Simplex/Chat/Store/Groups.hs | 16 ++++++------- src/Simplex/Chat/Store/Profiles.hs | 8 +++---- tests/ChatTests/Direct.hs | 8 +++++++ tests/ChatTests/Groups.hs | 33 +++++++++++++++++++++++++++ tests/ChatTests/Profiles.hs | 28 ++++++++++++++++++++++- tests/ChatTests/Utils.hs | 8 +++++++ 8 files changed, 118 insertions(+), 28 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ef56e42056..e224f04ebb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2222,7 +2222,7 @@ processChatCommand = \case processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ updateSettings chatSettings connectPlan :: User -> AConnectionRequestUri -> m ConnectionPlan connectPlan user (ACR SCMInvitation cReq) = do - withStore' (\db -> getConnectionEntityByConnReq db user cReq) >>= \case + withStore' (\db -> getConnectionEntityByConnReq db user cReqSchemas) >>= \case Nothing -> pure $ CPInvitationLink ILPOk Just (RcvDirectMsgConnection conn ct_) -> do let Connection {connStatus, contactConnInitiated} = conn @@ -2235,17 +2235,23 @@ processChatCommand = \case Just ct -> pure $ CPInvitationLink (ILPKnown ct) Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + where + cReqSchemas :: (ConnReqInvitation, ConnReqInvitation) + cReqSchemas = case cReq of + (CRInvitationUri crData e2e) -> + ( CRInvitationUri crData {crScheme = CRSSimplex} e2e, + CRInvitationUri crData {crScheme = simplexChat} e2e + ) connectPlan user (ACR SCMContact cReq) = do let CRContactUri ConnReqUriData {crClientData} = cReq groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli case groupLinkId of -- contact address Nothing -> - withStore' (`getUserContactLinkByConnReq` cReq) >>= \case + withStore' (`getUserContactLinkByConnReq` cReqSchemas) >>= \case Just _ -> pure $ CPContactAddress CAPOwnLink Nothing -> do - let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHash) >>= \case + withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHashes) >>= \case Nothing -> pure $ CPContactAddress CAPOk Just (RcvDirectMsgConnection _conn Nothing) -> pure $ CPContactAddress CAPConnectingConfirmReconnect Just (RcvDirectMsgConnection _ (Just ct)) @@ -2255,12 +2261,11 @@ processChatCommand = \case Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" -- group link Just _ -> - withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReq) >>= \case + withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReqSchemas) >>= \case Just g -> pure $ CPGroupLink (GLPOwnLink g) Nothing -> do - let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - connEnt_ <- withStore' $ \db -> getContactConnEntityByConnReqHash db user cReqHash - gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHash + connEnt_ <- withStore' $ \db -> getContactConnEntityByConnReqHash db user cReqHashes + gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHashes case (gInfo_, connEnt_) of (Nothing, Nothing) -> pure $ CPGroupLink GLPOk (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect @@ -2273,6 +2278,16 @@ processChatCommand = \case pure $ CPGroupLink (GLPConnectingProhibit gInfo_) | memberActive membership -> pure $ CPGroupLink (GLPKnown gInfo) | otherwise -> pure $ CPGroupLink GLPOk + where + cReqSchemas :: (ConnReqContact, ConnReqContact) + cReqSchemas = case cReq of + (CRContactUri crData) -> + ( CRContactUri crData {crScheme = CRSSimplex}, + CRContactUri crData {crScheme = simplexChat} + ) + cReqHashes :: (ConnReqUriHash, ConnReqUriHash) + cReqHashes = bimap hash hash cReqSchemas + hash = ConnReqUriHash . C.sha256Hash . strEncode assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m () assertDirectAllowed user dir ct event = diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index d8ec38269f..59ffb57c6e 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -154,18 +154,18 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound -getConnectionEntityByConnReq :: DB.Connection -> User -> ConnReqInvitation -> IO (Maybe ConnectionEntity) -getConnectionEntityByConnReq db user cReq = do +getConnectionEntityByConnReq :: DB.Connection -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq db user (cReqSchema1, cReqSchema2) = do connId_ <- maybeFirstRow fromOnly $ - DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv = ? LIMIT 1" (Only cReq) + DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv IN (?,?) LIMIT 1" (cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ -- search connection for connection plan: -- multiple connections can have same via_contact_uri_hash if request was repeated; -- this function searches for latest connection with contact so that "known contact" plan would be chosen; -- deleted connections are filtered out to allow re-connecting via same contact address -getContactConnEntityByConnReqHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe ConnectionEntity) -getContactConnEntityByConnReqHash db user cReqHash = do +getContactConnEntityByConnReqHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash db user (cReqHash1, cReqHash2) = do connId_ <- maybeFirstRow fromOnly $ DB.query db @@ -175,12 +175,12 @@ getContactConnEntityByConnReqHash db user cReqHash = do agent_conn_id, (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord FROM connections - WHERE via_contact_uri_hash = ? AND conn_status != ? + WHERE via_contact_uri_hash IN (?,?) AND conn_status != ? ORDER BY conn_ord DESC, created_at DESC LIMIT 1 ) |] - (cReqHash, ConnDeleted) + (cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 30e45a82dc..a4a19816da 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1121,21 +1121,21 @@ getGroupInfo db User {userId, userContactId} groupId = |] (groupId, userId, userContactId) -getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> ConnReqContact -> IO (Maybe GroupInfo) -getGroupInfoByUserContactLinkConnReq db user cReq = do +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq db user (cReqSchema1, cReqSchema2) = do groupId_ <- maybeFirstRow fromOnly $ DB.query db [sql| SELECT group_id FROM user_contact_links - WHERE conn_req_contact = ? + WHERE conn_req_contact IN (?,?) |] - (Only cReq) + (cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ -getGroupInfoByGroupLinkHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe GroupInfo) -getGroupInfoByGroupLinkHash db user@User {userId, userContactId} groupLinkHash = do +getGroupInfoByGroupLinkHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash db user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do groupId_ <- maybeFirstRow fromOnly $ DB.query db @@ -1143,11 +1143,11 @@ getGroupInfoByGroupLinkHash db user@User {userId, userContactId} groupLinkHash = SELECT g.group_id FROM groups g JOIN group_members mu ON mu.group_id = g.group_id - WHERE g.user_id = ? AND g.via_group_link_uri_hash = ? + WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) LIMIT 1 |] - (userId, groupLinkHash, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index fa573e4e60..c9c4806496 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -441,17 +441,17 @@ getUserContactLinkById db userId userContactLinkId = |] (userId, userContactLinkId) -getUserContactLinkByConnReq :: DB.Connection -> ConnReqContact -> IO (Maybe UserContactLink) -getUserContactLinkByConnReq db cReq = +getUserContactLinkByConnReq :: DB.Connection -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) +getUserContactLinkByConnReq db (cReqSchema1, cReqSchema2) = maybeFirstRow toUserContactLink $ DB.query db [sql| SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links - WHERE conn_req_contact = ? + WHERE conn_req_contact IN (?,?) |] - (Only cReq) + (cReqSchema1, cReqSchema2) updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept db user@User {userId} autoAccept = do diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 677bc4c09d..1a133fd8e3 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -270,6 +270,10 @@ testPlanInvitationLinkOwn tmp = alice ##> ("/_connect plan 1 " <> inv) alice <## "invitation link: own link" + let invSchema2 = linkAnotherSchema inv + alice ##> ("/_connect plan 1 " <> invSchema2) + alice <## "invitation link: own link" + alice ##> ("/c " <> inv) alice <## "confirmation sent!" alice @@ -305,6 +309,10 @@ testPlanInvitationLinkConnecting tmp = do bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: connecting" + let invSchema2 = linkAnotherSchema inv + bob ##> ("/_connect plan 1 " <> invSchema2) + bob <## "invitation link: connecting" + testContactClear :: HasCallStack => FilePath -> IO () testContactClear = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 43498711e7..7a8b1368bf 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2290,6 +2290,11 @@ testPlanGroupLinkOkKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + bob ##> ("/c " <> gLink) bob <## "group link: known group #team" bob <## "use #team to send messages" @@ -2331,6 +2336,11 @@ testPlanHostContactDeletedGroupLinkKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + bob ##> ("/c " <> gLink) bob <## "group link: known group #team" bob <## "use #team to send messages" @@ -2347,6 +2357,10 @@ testPlanGroupLinkOwn tmp = alice ##> ("/_connect plan 1 " <> gLink) alice <## "group link: own link for group #team" + let gLinkSchema2 = linkAnotherSchema gLink + alice ##> ("/_connect plan 1 " <> gLinkSchema2) + alice <## "group link: own link for group #team" + alice ##> ("/c " <> gLink) alice <## "connection request sent!" alice <## "alice_1 (Alice): accepting request to join group #team..." @@ -2373,6 +2387,9 @@ testPlanGroupLinkOwn tmp = alice ##> ("/_connect plan 1 " <> gLink) alice <## "group link: own link for group #team" + alice ##> ("/_connect plan 1 " <> gLinkSchema2) + alice <## "group link: own link for group #team" + -- group works if merged contact is deleted alice ##> "/d alice_1" alice <## "alice_1: contact is deleted" @@ -2405,6 +2422,10 @@ testPlanGroupLinkConnecting tmp = do bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: connecting, allowed to reconnect" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting, allowed to reconnect" + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice @@ -2417,6 +2438,10 @@ testPlanGroupLinkConnecting tmp = do bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: connecting" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting" + bob ##> ("/c " <> gLink) bob <## "group link: connecting" @@ -2462,6 +2487,10 @@ testPlanGroupLinkLeaveRejoin = bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: ok to connect" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: ok to connect" + bob ##> ("/c " <> gLink) bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." @@ -2490,6 +2519,10 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team_1" + bob <## "use #team_1 to send messages" + bob ##> ("/c " <> gLink) bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 5682d09ee8..bd7f4c1682 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -599,6 +599,11 @@ testPlanAddressOkKnown = bob <## "contact address: known contact alice" bob <## "use @alice to send messages" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + bob ##> ("/c " <> cLink) bob <## "contact address: known contact alice" bob <## "use @alice to send messages" @@ -612,11 +617,15 @@ testPlanAddressOwn tmp = alice ##> ("/_connect plan 1 " <> cLink) alice <## "contact address: own address" + let cLinkSchema2 = linkAnotherSchema cLink + alice ##> ("/_connect plan 1 " <> cLinkSchema2) + alice <## "contact address: own address" + alice ##> ("/c " <> cLink) alice <## "connection request sent!" alice <## "alice_1 (Alice) wants to connect to you!" alice <## "to accept: /ac alice_1" - alice <## ("to reject: /rc alice_1 (the sender will NOT be notified)") + alice <## "to reject: /rc alice_1 (the sender will NOT be notified)" alice @@@ [("<@alice_1", ""), (":2","")] alice ##> "/ac alice_1" alice <## "alice_1 (Alice): accepting contact request..." @@ -658,6 +667,10 @@ testPlanAddressConnecting tmp = do bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: connecting, allowed to reconnect" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: connecting, allowed to reconnect" + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" @@ -672,6 +685,10 @@ testPlanAddressConnecting tmp = do bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: connecting to contact alice" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: connecting to contact alice" + bob ##> ("/c " <> cLink) bob <## "contact address: connecting to contact alice" @@ -706,6 +723,10 @@ testPlanAddressContactDeletedReconnected = bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: ok to connect" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: ok to connect" + bob ##> ("/c " <> cLink) bob <## "connection request sent!" alice <## "bob (Bob) wants to connect to you!" @@ -726,6 +747,11 @@ testPlanAddressContactDeletedReconnected = bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: known contact alice_1" + bob <## "use @alice_1 to send messages" + bob ##> ("/c " <> cLink) bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index d98431e815..fcb6b65e41 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -559,3 +559,11 @@ currentChatVRangeInfo = vRangeStr :: VersionRange -> String vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" + +linkAnotherSchema :: String -> String +linkAnotherSchema link + | "https://simplex.chat/" `isPrefixOf` link = + T.unpack $ T.replace "https://simplex.chat/" "simplex:/" $ T.pack link + | "simplex:/" `isPrefixOf` link = + T.unpack $ T.replace "simplex:/" "https://simplex.chat/" $ T.pack link + | otherwise = error "link starts with neither https://simplex.chat/ nor simplex:/" From e4c8386f3ffc868afc210d432dd5a97216f2fe7a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 16 Oct 2023 19:23:38 +0400 Subject: [PATCH 085/294] core: replace simplex:/ with simplex.chat links in view; remove trustedUri flag from simplex links markdown format (#3235) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Markdown.hs | 11 ++++------- src/Simplex/Chat/View.hs | 14 ++++++++++---- stack.yaml | 2 +- tests/ChatTests/Profiles.hs | 1 - tests/MarkdownTests.hs | 14 +++++++------- tests/ProtocolTests.hs | 22 +++++++++++----------- 9 files changed, 36 insertions(+), 34 deletions(-) diff --git a/cabal.project b/cabal.project index 3da442ac49..9a9a3e25da 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 919550948501d315aa8845cbed1781d4298d4ced + tag: 1ad69cf74f18f25713ce564e1629d2538313b9e0 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0a199779d0..17d650cb09 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."919550948501d315aa8845cbed1781d4298d4ced" = "05d0cadhlazqi2lxcb7nvyjrf8q49c6ax7b8rahawbh1zmwg38nm"; + "https://github.com/simplex-chat/simplexmq.git"."1ad69cf74f18f25713ce564e1629d2538313b9e0" = "1kil0962pn3ksnxh7dcwcbnkidz95yl31rm4m585ps7wnh6fp0l9"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e224f04ebb..89f64189af 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -5936,7 +5936,7 @@ chatCommandP = adminContactReq :: ConnReqContact adminContactReq = - either error id $ strDecode "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" + either error id $ strDecode "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" timeItToView :: ChatMonad' m => String -> m a -> m a timeItToView s action = do diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index d18f28db31..30990f225d 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -32,7 +32,7 @@ import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, sumTypeJSON) -import Simplex.Messaging.Protocol (ProtocolServer (..), SrvLoc (..)) +import Simplex.Messaging.Protocol (ProtocolServer (..)) import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types import qualified Text.Email.Validate as Email @@ -48,7 +48,7 @@ data Format | Secret | Colored {color :: FormatColor} | Uri - | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, trustedUri :: Bool, smpHosts :: NonEmpty Text} + | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} | Email | Phone deriving (Eq, Show, Generic) @@ -229,15 +229,12 @@ markdownP = mconcat <$> A.many' fragmentP simplexUriFormat = \case ACR _ (CRContactUri crData) -> let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = CRSSimplex} - in SimplexLink (linkType' crData) uri (trustedUri' crData) $ uriHosts crData + in SimplexLink (linkType' crData) uri $ uriHosts crData ACR _ (CRInvitationUri crData e2e) -> let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = CRSSimplex} e2e - in SimplexLink XLInvitation uri (trustedUri' crData) $ uriHosts crData + in SimplexLink XLInvitation uri $ uriHosts crData where uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues - trustedUri' ConnReqUriData {crScheme} = case crScheme of - CRSSimplex -> True - CRSAppServer (SrvLoc host _) -> host == "simplex.chat" linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of Just (CRDataGroup _) -> XLGroup Nothing -> XLContact diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index fa2213d083..07cbec601f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -693,11 +693,14 @@ viewConnReqInvitation :: ConnReqInvitation -> [StyledString] viewConnReqInvitation cReq = [ "pass this invitation link to your contact (via another channel): ", "", - (plain . strEncode) cReq, + (plain . strEncode) (simplexChatInvitation cReq), "", "and ask them to connect: " <> highlight' "/c " ] +simplexChatInvitation :: ConnReqInvitation -> ConnReqInvitation +simplexChatInvitation (CRInvitationUri crData e2e) = CRInvitationUri crData {crScheme = simplexChat} e2e + viewContactNotFound :: ContactName -> Maybe (GroupInfo, GroupMember) -> [StyledString] viewContactNotFound cName suspectedMember = ["no contact " <> ttyContact cName <> useMessageMember] @@ -736,7 +739,7 @@ connReqContact_ :: StyledString -> ConnReqContact -> [StyledString] connReqContact_ intro cReq = [ intro, "", - (plain . strEncode) cReq, + (plain . strEncode) (simplexChatContact cReq), "", "Anybody can send you contact requests with: " <> highlight' "/c ", "to show it again: " <> highlight' "/sa", @@ -744,6 +747,9 @@ connReqContact_ intro cReq = "to delete it: " <> highlight' "/da" <> " (accepted contacts will remain connected)" ] +simplexChatContact :: ConnReqContact -> ConnReqContact +simplexChatContact (CRContactUri crData) = CRContactUri crData {crScheme = simplexChat} + autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString] autoAcceptStatus_ = \case Just AutoAccept {acceptIncognito, autoReply} -> @@ -755,7 +761,7 @@ groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> groupLink_ intro g cReq mRole = [ intro, "", - (plain . strEncode) cReq, + (plain . strEncode) (simplexChatContact cReq), "", "Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c ", "to show it again: " <> highlight ("/show link #" <> viewGroupName g), @@ -1022,7 +1028,7 @@ viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn} stats incognitoProfile = ["contact ID: " <> sShow contactId] <> viewConnectionStats stats - <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) l]) contactLink + <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink <> maybe ["you've shared main profile with this contact"] (\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p]) diff --git a/stack.yaml b/stack.yaml index 5d9fc214fb..6e047f7e6c 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 919550948501d315aa8845cbed1781d4298d4ced + commit: 1ad69cf74f18f25713ce564e1629d2538313b9e0 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index bd7f4c1682..68b925342e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -747,7 +747,6 @@ testPlanAddressContactDeletedReconnected = bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" - let cLinkSchema2 = linkAnotherSchema cLink bob ##> ("/_connect plan 1 " <> cLinkSchema2) bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 837849d7e4..83a180c745 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -137,8 +137,8 @@ textColor = describe "text color (red)" do uri :: Text -> Markdown uri = Markdown $ Just Uri -simplexLink :: SimplexLinkType -> Text -> Bool -> NonEmpty Text -> Text -> Markdown -simplexLink linkType simplexUri trustedUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, trustedUri, smpHosts} +simplexLink :: SimplexLinkType -> Text -> NonEmpty Text -> Text -> Markdown +simplexLink linkType simplexUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, smpHosts} textWithUri :: Spec textWithUri = describe "text with Uri" do @@ -152,13 +152,13 @@ textWithUri = describe "text with Uri" do parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" - parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) True ["smp.simplex.im"] ("https://simplex.chat" <> inv) - parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) True ["smp.simplex.im"] ("simplex:" <> inv) - parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) False ["smp.simplex.im"] ("https://example.com" <> inv) + parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) + parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) + parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) let ct = "/contact#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D" - parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) True ["smp.simplex.im"] ("https://simplex.chat" <> ct) + parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) let gr = "/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" - parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) True ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) email :: Text -> Markdown email = Markdown $ Just Email diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index d62d7a470a..0b99c5a4d8 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -39,7 +39,7 @@ queue = connReqData :: ConnReqUriData connReqData = ConnReqUriData - { crScheme = simplexChat, + { crScheme = CRSSimplex, crAgentVRange = mkVersionRange 1 1, crSmpQueues = [queue], crClientData = Nothing @@ -184,7 +184,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" @@ -193,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" @@ -220,10 +220,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing} it "x.grp.inv with group link id" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} it "x.grp.acpt without incognito profile" $ "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" @@ -241,16 +241,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.inv w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -271,10 +271,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.grp.direct.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XGrpDirectInv testConnReq (Just $ MCText "hello") it "x.grp.direct.inv without content" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XGrpDirectInv testConnReq Nothing it "x.info.probe" $ "{\"v\":\"1\",\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" From c7a8992043d86cc5cf089d4ec18df5651e8defc4 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 16 Oct 2023 20:05:13 +0400 Subject: [PATCH 086/294] core: fix compilation for ghc 8.10.7 --- src/Simplex/Chat.hs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 16b804471d..3f377fbd70 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2234,6 +2234,7 @@ processChatCommand = \case ( CRInvitationUri crData {crScheme = CRSSimplex} e2e, CRInvitationUri crData {crScheme = simplexChat} e2e ) + _ -> (cReq, cReq) -- ghc8107 connectPlan user (ACR SCMContact cReq) = do let CRContactUri ConnReqUriData {crClientData} = cReq groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli @@ -2277,6 +2278,7 @@ processChatCommand = \case ( CRContactUri crData {crScheme = CRSSimplex}, CRContactUri crData {crScheme = simplexChat} ) + _ -> (cReq, cReq) -- ghc8107 cReqHashes :: (ConnReqUriHash, ConnReqUriHash) cReqHashes = bimap hash hash cReqSchemas hash = ConnReqUriHash . C.sha256Hash . strEncode From 99c458406f55683f408b27223e8a01263035b25e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 17 Oct 2023 11:44:35 +0400 Subject: [PATCH 087/294] android: remove browser mode for simplex links (#3239) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 2 +- .../chat/simplex/common/views/chat/item/TextItemView.kt | 8 ++------ .../simplex/common/views/usersettings/PrivacySettings.kt | 7 +++---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index a4b76cc79d..f3655d0008 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2416,7 +2416,7 @@ sealed class Format { @Serializable @SerialName("secret") class Secret: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() - @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List): Format() + @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index eabab138ba..ff1267d0fa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -136,12 +136,8 @@ fun MarkdownText ( val link = ft.link(linkMode) if (link != null) { hasLinks = true - val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) { - SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline) - } else { - ft.format.style - } - withAnnotation(tag = if (ft.format is Format.SimplexLink && linkMode != SimplexLinkMode.BROWSER) "SIMPLEX_URL" else "URL", annotation = link) { + val ftStyle = ft.format.style + withAnnotation(tag = if (ft.format is Format.SimplexLink) "SIMPLEX_URL" else "URL", annotation = link) { withStyle(ftStyle) { append(ft.viewText(linkMode)) } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 210d3c6136..211fe59671 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -92,9 +92,6 @@ fun PrivacySettingsView( chatModel.simplexLinkMode.value = it }) } - if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) { - SectionTextFooter(stringResource(MR.strings.simplex_link_mode_browser_warning)) - } SectionDividerSpaced() val currentUser = chatModel.currentUser.value @@ -185,8 +182,10 @@ fun PrivacySettingsView( @Composable private fun SimpleXLinkOptions(simplexLinkModeState: State, onSelected: (SimplexLinkMode) -> Unit) { + val modeValues = listOf(SimplexLinkMode.DESCRIPTION, SimplexLinkMode.FULL) + val pickerValues = modeValues + if (modeValues.contains(simplexLinkModeState.value)) emptyList() else listOf(simplexLinkModeState.value) val values = remember { - SimplexLinkMode.values().map { + pickerValues.map { when (it) { SimplexLinkMode.DESCRIPTION -> it to generalGetString(MR.strings.simplex_link_mode_description) SimplexLinkMode.FULL -> it to generalGetString(MR.strings.simplex_link_mode_full) From d8d47d706d50fa3f18d1b5ac8a736deb63b05b10 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 17 Oct 2023 12:56:12 +0400 Subject: [PATCH 088/294] ios: connection plan improvements; remove browser mode for simplex links (#3237) --- apps/ios/Shared/ContentView.swift | 5 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 4 +- .../Views/Chat/ChatItem/MsgContentView.swift | 6 +- .../Views/Chat/Group/GroupLinkView.swift | 4 +- .../Chat/Group/GroupMemberInfoView.swift | 4 +- .../ChatList/ContactConnectionInfo.swift | 2 +- .../Shared/Views/NewChat/AddContactView.swift | 4 +- .../Shared/Views/NewChat/NewChatButton.swift | 56 ++++++++++++++++--- apps/ios/Shared/Views/NewChat/QRCode.swift | 16 ++++++ .../Onboarding/CreateSimpleXAddress.swift | 4 +- .../Views/UserSettings/PrivacySettings.swift | 8 +-- .../Views/UserSettings/SettingsView.swift | 2 +- .../Views/UserSettings/UserAddressView.swift | 4 +- apps/ios/SimpleXChat/APITypes.swift | 6 +- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- 15 files changed, 88 insertions(+), 39 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 04eadac2c1..b69ccbb7c7 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -292,10 +292,7 @@ struct ContentView: View { var path = url.path if (path == "/contact" || path == "/invitation") { path.removeFirst() - // TODO normalize in backend; revert - // let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - var link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - link = link.starts(with: "simplex:/") ? link.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") : link + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") planAndConnect( link, showAlert: showPlanAndConnectAlert, diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 81412bf310..5438eb13bc 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -168,9 +168,9 @@ struct ChatInfoView: View { if let contactLink = contact.contactLink { Section { - QRCode(uri: contactLink) + SimpleXLinkQRCode(uri: contactLink) Button { - showShareSheet(items: [contactLink]) + showShareSheet(items: [simplexChatLink(contactLink)]) } label: { Label("Share address", systemImage: "square.and.arrow.up") } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 498b3cb2e0..8b757ed1a3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -121,13 +121,11 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text { case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary) case let .colored(color): return Text(t).foregroundColor(color.uiColor) case .uri: return linkText(t, t, preview, prefix: "") - case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts): + case let .simplexLink(linkType, simplexUri, smpHosts): switch privacySimplexLinkModeDefault.get() { case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "") case .full: return linkText(t, simplexUri, preview, prefix: "") - case .browser: return trustedUri - ? linkText(t, t, preview, prefix: "") - : linkText(t, t, preview, prefix: "", color: .red, uiColor: .red) + case .browser: return linkText(t, simplexUri, preview, prefix: "") } case .email: return linkText(t, t, preview, prefix: "mailto:") case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 3731e0c4d7..781870bf5e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -41,9 +41,9 @@ struct GroupLinkView: View { } } .frame(height: 36) - QRCode(uri: groupLink) + SimpleXLinkQRCode(uri: groupLink) Button { - showShareSheet(items: [groupLink]) + showShareSheet(items: [simplexChatLink(groupLink)]) } label: { Label("Share link", systemImage: "square.and.arrow.up") } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 3d10101dee..4b64458145 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -94,9 +94,9 @@ struct GroupMemberInfoView: View { if let contactLink = member.contactLink { Section { - QRCode(uri: contactLink) + SimpleXLinkQRCode(uri: contactLink) Button { - showShareSheet(items: [contactLink]) + showShareSheet(items: [simplexChatLink(contactLink)]) } label: { Label("Share address", systemImage: "square.and.arrow.up") } diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index 3e42d2f207..7c973c73c6 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -61,7 +61,7 @@ struct ContactConnectionInfo: View { if contactConnection.initiated, let connReqInv = contactConnection.connReqInv { - QRCode(uri: connReqInv) + SimpleXLinkQRCode(uri: simplexChatLink(connReqInv)) incognitoEnabled() shareLinkButton(connReqInv) oneTimeLinkLearnMoreButton() diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 31b6b64f32..344a8d1f96 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -21,7 +21,7 @@ struct AddContactView: View { List { Section { if connReqInvitation != "" { - QRCode(uri: connReqInvitation) + SimpleXLinkQRCode(uri: connReqInvitation) } else { ProgressView() .progressViewStyle(.circular) @@ -99,7 +99,7 @@ func sharedProfileInfo(_ incognito: Bool) -> Text { func shareLinkButton(_ connReqInvitation: String) -> some View { Button { - showShareSheet(items: [connReqInvitation]) + showShareSheet(items: [simplexChatLink(connReqInvitation)]) } label: { settingsRow("square.and.arrow.up") { Text("Share 1-time link") diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index e4fbd8bd47..8d095e907b 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -62,7 +62,9 @@ enum PlanAndConnectAlert: Identifiable { case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case invitationLinkConnecting(connectionLink: String) case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?) var id: String { @@ -70,7 +72,9 @@ enum PlanAndConnectAlert: Identifiable { case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)" case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)" case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)" + case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)" case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" + case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)" case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" } } @@ -103,6 +107,16 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { ), secondaryButton: .cancel() ) + case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Repeat connection request?"), + message: Text("You have already requested connection via this address!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito): return Alert( title: Text("Join group?"), @@ -113,6 +127,16 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { ), secondaryButton: .cancel() ) + case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Repeat join request?"), + message: Text("You are already joining the group via this link!"), + primaryButton: .destructive( + Text(incognito ? "Join incognito" : "Join"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) case let .groupLinkConnecting(_, groupInfo): if let groupInfo = groupInfo { return Alert( @@ -130,13 +154,13 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { enum PlanAndConnectActionSheet: Identifiable { case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) - case ownLinkAskCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) + case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) var id: String { switch self { case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" - case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, _, _): return "ownLinkAskCurrentOrIncognitoProfile \(connectionLink)" + case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)" case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" } } @@ -153,7 +177,7 @@ func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool .cancel() ] ) - case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, connectionPlan, title): + case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title): return ActionSheet( title: Text(title), buttons: [ @@ -211,7 +235,7 @@ func planAndConnect( if let incognito = incognito { showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) } else { - showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) } case let .connecting(contact_): logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")") @@ -238,10 +262,17 @@ func planAndConnect( if let incognito = incognito { showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) } else { - showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) } - case let .connecting(contact): - logger.debug("planAndConnect, .contactAddress, .connecting, incognito=\(incognito?.description ?? "nil")") + case .connectingConfirmReconnect: + logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?")) + } + case let .connectingProhibit(contact): + logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } case let .known(contact): logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") @@ -258,8 +289,15 @@ func planAndConnect( case let .ownLink(groupInfo): logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")") showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) - case let .connecting(groupInfo_): - logger.debug("planAndConnect, .groupLink, .connecting, incognito=\(incognito?.description ?? "nil")") + case .connectingConfirmReconnect: + logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?")) + } + case let .connectingProhibit(groupInfo_): + logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) case let .known(groupInfo): logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index 0785826fa9..4bec3f8e66 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -28,6 +28,22 @@ struct MutableQRCode: View { } } +struct SimpleXLinkQRCode: View { + let uri: String + var withLogo: Bool = true + var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) + + var body: some View { + QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor) + } +} + +func simplexChatLink(_ uri: String) -> String { + uri.starts(with: "simplex:/") + ? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") + : uri +} + struct QRCode: View { let uri: String var withLogo: Bool = true diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 049c4bee08..249180686c 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View { Spacer() if let userAddress = m.userAddress { - QRCode(uri: userAddress.connReqContact) + SimpleXLinkQRCode(uri: userAddress.connReqContact) .frame(maxHeight: g.size.width) shareQRCodeButton(userAddress) .frame(maxWidth: .infinity) @@ -126,7 +126,7 @@ struct CreateSimpleXAddress: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [userAddress.connReqContact]) + showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) } label: { Label("Share", systemImage: "square.and.arrow.up") } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 16555fad3a..71ff7b88bf 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -93,7 +93,9 @@ struct PrivacySettings: View { } settingsRow("link") { Picker("SimpleX links", selection: $simplexLinkMode) { - ForEach(SimpleXLinkMode.values) { mode in + ForEach( + SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode]) + ) { mode in Text(mode.text) } } @@ -104,10 +106,6 @@ struct PrivacySettings: View { } } header: { Text("Chats") - } footer: { - if case .browser = simplexLinkMode { - Text("Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.") - } } Section { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f076a4eb7d..1cc859f49e 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -93,7 +93,7 @@ enum SimpleXLinkMode: String, Identifiable { case full case browser - static var values: [SimpleXLinkMode] = [.description, .full, .browser] + static var values: [SimpleXLinkMode] = [.description, .full] public var id: Self { self } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 86bf0048b8..60e5cc7b04 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -190,7 +190,7 @@ struct UserAddressView: View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { Section { - MutableQRCode(uri: Binding.constant(userAddress.connReqContact)) + MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact))) shareQRCodeButton(userAddress) if MFMailComposeViewController.canSendMail() { shareViaEmailButton(userAddress) @@ -248,7 +248,7 @@ struct UserAddressView: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [userAddress.connReqContact]) + showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) } label: { settingsRow("square.and.arrow.up") { Text("Share address") diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ddab4c73bd..53da91b042 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -886,14 +886,16 @@ public enum InvitationLinkPlan: Decodable { public enum ContactAddressPlan: Decodable { case ok case ownLink - case connecting(contact: Contact) + case connectingConfirmReconnect + case connectingProhibit(contact: Contact) case known(contact: Contact) } public enum GroupLinkPlan: Decodable { case ok case ownLink(groupInfo: GroupInfo) - case connecting(groupInfo_: GroupInfo?) + case connectingConfirmReconnect + case connectingProhibit(groupInfo_: GroupInfo?) case known(groupInfo: GroupInfo) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index caaccb5454..c31786c700 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3068,7 +3068,7 @@ public enum Format: Decodable, Equatable { case secret case colored(color: FormatColor) case uri - case simplexLink(linkType: SimplexLinkType, simplexUri: String, trustedUri: Bool, smpHosts: [String]) + case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) case email case phone } From 29c8ab7c9b964a6a227560ec4a928ed3c6e15fa4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 17 Oct 2023 18:05:16 +0100 Subject: [PATCH 089/294] ui: file & media preference for groups (#3243) --- .../Views/Chat/Group/GroupPreferencesView.swift | 3 +-- .../common/views/chat/group/GroupPreferences.kt | 11 +++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index af1a778ad1..860a6febb0 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -27,8 +27,7 @@ struct GroupPreferencesView: View { featureSection(.directMessages, $preferences.directMessages.enable) featureSection(.reactions, $preferences.reactions.enable) featureSection(.voice, $preferences.voice.enable) -// TODO uncomment in 5.3 -// featureSection(.files, $preferences.files.enable) + featureSection(.files, $preferences.files.enable) if groupInfo.canEdit { Section { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index d95d2a4cff..4571a38c13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -103,12 +103,11 @@ private fun GroupPreferencesLayout( FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) { applyPrefs(preferences.copy(voice = GroupPreference(enable = it))) } -// TODO uncomment in 5.3 -// SectionDividerSpaced(true, maxBottomPadding = false) -// val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) } -// FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) { -// applyPrefs(preferences.copy(files = GroupPreference(enable = it))) -// } + SectionDividerSpaced(true, maxBottomPadding = false) + val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) } + FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) { + applyPrefs(preferences.copy(files = GroupPreference(enable = it))) + } if (groupInfo.canEdit) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) ResetSaveButtons( From a02886ca5d46a444d7edd4645a415b2c16b8dd9d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 18 Oct 2023 10:19:24 +0100 Subject: [PATCH 090/294] core: fix editing and status changes removing reactions from view (#3245) * core: fix editing and status changes removing reactions from view * refactor * refactor 2 * case --- src/Simplex/Chat.hs | 19 ++++--- src/Simplex/Chat/Store/Messages.hs | 80 ++++++++++++++++-------------- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 89f64189af..82499baee6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -760,8 +760,9 @@ processChatCommand = \case unzipMaybe3 _ = (Nothing, Nothing, Nothing) APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> withChatLock "updateChatItem" $ case cType of CTDirect -> do - (ct@Contact {contactId}, cci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + ct@Contact {contactId} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ + cci <- withStore $ \db -> getDirectCIWithReactions db user ct itemId case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of @@ -783,7 +784,7 @@ processChatCommand = \case CTGroup -> do Group gInfo@GroupInfo {groupId} ms <- withStore $ \db -> getGroup db user chatId assertUserGroupRole gInfo GRAuthor - cci <- withStore $ \db -> getGroupChatItem db user chatId itemId + cci <- withStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of @@ -2390,8 +2391,8 @@ updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do forM_ aciContent_ $ \aciContent -> updateDirectChatItemView user ct chatItemId aciContent False msgId_ updateDirectChatItemView :: ChatMonad m => User -> Contact -> ChatItemId -> ACIContent -> Bool -> Maybe MessageId -> m () -updateDirectChatItemView user ct@Contact {contactId} chatItemId (ACIContent msgDir ciContent) live msgId_ = do - ci' <- withStore $ \db -> updateDirectChatItem db user contactId chatItemId ciContent live msgId_ +updateDirectChatItemView user ct chatItemId (ACIContent msgDir ciContent) live msgId_ = do + ci' <- withStore $ \db -> updateDirectChatItem db user ct chatItemId ciContent live msgId_ toView $ CRChatItemUpdated user (AChatItem SCTDirect msgDir (DirectChat ct) ci') callStatusItemContent :: ChatMonad m => User -> Contact -> ChatItemId -> WebRTCCallStatus -> m (Maybe ACIContent) @@ -3996,7 +3997,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci' <- withStore' $ \db -> do when changed $ addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - updateDirectChatItem' db user contactId ci content live $ Just msgId + reactions <- getDirectCIReactions db ct sharedMsgId + updateDirectChatItem' db user contactId ci {reactions} content live $ Just msgId toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) @@ -4134,7 +4136,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci' <- withStore' $ \db -> do when changed $ addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - updateGroupChatItem db user groupId ci content live $ Just msgId + reactions <- getGroupCIReactions db gInfo memberId sharedMsgId + updateGroupChatItem db user groupId ci {reactions} content live $ Just msgId toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) @@ -4939,7 +4942,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}}) | itemStatus == newStatus -> pure () | otherwise -> do - chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId itemId newStatus + chatItem <- withStore $ \db -> updateDirectChatItemStatus db user ct itemId newStatus toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) _ -> pure () @@ -4962,7 +4965,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do memStatusCounts <- withStore' (`getGroupSndStatusCounts` itemId) let newStatus = membersGroupItemStatus memStatusCounts when (newStatus /= itemStatus) $ do - chatItem <- withStore $ \db -> updateGroupChatItemStatus db user groupId itemId newStatus + chatItem <- withStore $ \db -> updateGroupChatItemStatus db user gInfo itemId newStatus toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 0f9abaa465..35a8bad698 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -15,7 +15,6 @@ module Simplex.Chat.Store.Messages ( getContactConnIds_, - getDirectChatReactions_, -- * Message and chat item functions deleteContactCIs, @@ -68,9 +67,11 @@ module Simplex.Chat.Store.Messages setGroupReaction, getChatItemIdByAgentMsgId, getDirectChatItem, + getDirectCIWithReactions, getDirectChatItemBySharedMsgId, getDirectChatItemByAgentMsgId, getGroupChatItem, + getGroupCIWithReactions, getGroupChatItemBySharedMsgId, getGroupMemberCIBySharedMsgId, getGroupChatItemByAgentMsgId, @@ -755,7 +756,7 @@ getGroupChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String getGroupChat db user groupId pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db user groupId - liftIO . getGroupChatReactions_ db g =<< case pagination of + case pagination of CPLast count -> getGroupChatLast_ db user g count search CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search @@ -764,7 +765,7 @@ getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> Exce getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItemIds <- liftIO getGroupChatItemIdsLast_ - chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds + chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] @@ -802,7 +803,7 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} afterChatItem <- getGroupChatItem db user groupId afterChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) - chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds + chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds pure $ Chat (GroupChat g) chatItems stats where getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] @@ -825,7 +826,7 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) - chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds + chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] @@ -1149,23 +1150,24 @@ getChatItemIdByAgentMsgId db connId msgId = |] (connId, msgId) -updateDirectChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTDirect d) -updateDirectChatItemStatus db user@User {userId} contactId itemId itemStatus = do - ci <- liftEither . correctDir =<< getDirectChatItem db user contactId itemId +updateDirectChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Contact -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItemStatus db user@User {userId} ct@Contact {contactId} itemId itemStatus = do + ci <- liftEither . correctDir =<< getDirectCIWithReactions db user ct itemId currentTs <- liftIO getCurrentTime liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, contactId, itemId) pure ci {meta = (meta ci) {itemStatus}} - where - correctDir :: CChatItem c -> Either StoreError (ChatItem c d) - correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) -updateDirectChatItem db user contactId itemId newContent live msgId_ = do - ci <- liftEither . correctDir =<< getDirectChatItem db user contactId itemId +updateDirectChatItem :: MsgDirectionI d => DB.Connection -> User -> Contact -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItem db user ct@Contact {contactId} itemId newContent live msgId_ = do + ci <- liftEither . correctDir =<< getDirectCIWithReactions db user ct itemId liftIO $ updateDirectChatItem' db user contactId ci newContent live msgId_ - where - correctDir :: CChatItem c -> Either StoreError (ChatItem c d) - correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci + +getDirectCIWithReactions :: DB.Connection -> User -> Contact -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTDirect) +getDirectCIWithReactions db user ct@Contact {contactId} itemId = + liftIO . directCIWithReactions db ct =<< getDirectChatItem db user contactId itemId + +correctDir :: MsgDirectionI d => CChatItem c -> Either StoreError (ChatItem c d) +correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci updateDirectChatItem' :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTDirect d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTDirect d) updateDirectChatItem' db User {userId} contactId ci newContent live msgId_ = do @@ -1303,7 +1305,7 @@ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId = getDirectChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTDirect) getDirectChatItem db User {userId} contactId itemId = ExceptT $ do currentTs <- getCurrentTime - join <$> firstRow (toDirectChatItem currentTs) (SEChatItemNotFound itemId) getItem + firstRow' (toDirectChatItem currentTs) (SEChatItemNotFound itemId) getItem where getItem = DB.query @@ -1351,17 +1353,26 @@ getDirectChatItemIdByText' db User {userId} contactId msg = |] (userId, contactId, msg <> "%") -updateGroupChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> GroupId -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTGroup d) -updateGroupChatItemStatus db user@User {userId} groupId itemId itemStatus = do - ci <- liftEither . correctDir =<< getGroupChatItem db user groupId itemId +updateGroupChatItemStatus :: MsgDirectionI d => DB.Connection -> User -> GroupInfo -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTGroup d) +updateGroupChatItemStatus db user@User {userId} g@GroupInfo {groupId} itemId itemStatus = do + ci <- liftEither . correctDir =<< getGroupCIWithReactions db user g itemId currentTs <- liftIO getCurrentTime liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, groupId, itemId) pure ci {meta = (meta ci) {itemStatus}} - where - correctDir :: CChatItem c -> Either StoreError (ChatItem c d) - correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateGroupChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) +getGroupCIWithReactions :: DB.Connection -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupCIWithReactions db user g@GroupInfo {groupId} itemId = do + liftIO . groupCIWithReactions db g =<< getGroupChatItem db user groupId itemId + +groupCIWithReactions :: DB.Connection -> GroupInfo -> CChatItem 'CTGroup -> IO (CChatItem 'CTGroup) +groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of + Just sharedMsgId -> do + let GroupMember {memberId} = chatItemMember g ci + reactions <- getGroupCIReactions db g memberId sharedMsgId + pure $ CChatItem md ci {reactions} + Nothing -> pure cci + +updateGroupChatItem :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) updateGroupChatItem db user groupId ci newContent live msgId_ = do currentTs <- liftIO getCurrentTime let ci' = updatedChatItem ci newContent live currentTs @@ -1370,7 +1381,7 @@ updateGroupChatItem db user groupId ci newContent live msgId_ = do -- this function assumes that the group item with correct chat direction already exists, -- it should be checked before calling it -updateGroupChatItem_ :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> Maybe MessageId -> IO () +updateGroupChatItem_ :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> Maybe MessageId -> IO () updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = do let CIMeta {itemId, itemText, itemStatus, itemDeleted, itemEdited, itemTimed, itemLive, updatedAt} = meta itemDeleted' = isJust itemDeleted @@ -1501,7 +1512,7 @@ getGroupChatItemByAgentMsgId db user groupId connId msgId = do getGroupChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do currentTs <- getCurrentTime - join <$> firstRow (toGroupChatItem currentTs userContactId) (SEChatItemNotFound itemId) getItem + firstRow' (toGroupChatItem currentTs userContactId) (SEChatItemNotFound itemId) getItem where getItem = DB.query @@ -1671,18 +1682,15 @@ getChatItemVersions db itemId = do getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect) getDirectChatReactions_ db ct c@Chat {chatItems} = do - chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do - reactions <- maybe (pure []) (getDirectCIReactions db ct) itemSharedMsgId - pure $ CChatItem md ci {reactions} + chatItems' <- mapM (directCIWithReactions db ct) chatItems pure c {chatItems = chatItems'} -getGroupChatReactions_ :: DB.Connection -> GroupInfo -> Chat 'CTGroup -> IO (Chat 'CTGroup) -getGroupChatReactions_ db g c@Chat {chatItems} = do - chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do - let GroupMember {memberId} = chatItemMember g ci - reactions <- maybe (pure []) (getGroupCIReactions db g memberId) itemSharedMsgId +directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect) +directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of + Just sharedMsgId -> do + reactions <- getDirectCIReactions db ct sharedMsgId pure $ CChatItem md ci {reactions} - pure c {chatItems = chatItems'} + Nothing -> pure cci getDirectCIReactions :: DB.Connection -> Contact -> SharedMsgId -> IO [CIReactionCount] getDirectCIReactions db Contact {contactId} itemSharedMsgId = From 706d6bf65b8affea286d5727a34f0500647edc21 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 18 Oct 2023 11:23:35 +0100 Subject: [PATCH 091/294] core, ui: prevent old sent items re-added to chat, and "new" status overriding "sent" (#3246) * core, ui: prevent old sent items re-added to chat, and "new" status overriding "sent" * clear item statuses when changing current chat * remove iOS hack * remote state/published from chatItemStatuses --- apps/ios/Shared/Model/ChatModel.swift | 18 ++++++++++-------- apps/ios/Shared/Model/SimpleXAPI.swift | 8 +++----- apps/ios/Shared/Views/Chat/ChatView.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 14 ++++++++++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 7 ++----- .../views/chat/group/GroupMemberInfoView.kt | 1 + .../views/chatlist/ChatListNavLinkView.kt | 1 + .../common/views/newchat/AddGroupView.kt | 1 + src/Simplex/Chat.hs | 8 +++----- 9 files changed, 34 insertions(+), 25 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index b1a83dd5e6..3cc52d502d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -62,6 +62,7 @@ final class ChatModel: ObservableObject { // current chat @Published var chatId: String? @Published var reversedChatItems: [ChatItem] = [] + var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? @Published var groupMembers: [GroupMember] = [] // items in the terminal view @@ -306,7 +307,11 @@ final class ChatModel: ObservableObject { return false } else { withAnimation(itemAnimation()) { - reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) + var ci = cItem + if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { + ci.meta.itemStatus = status + } + reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) } return true } @@ -319,23 +324,19 @@ final class ChatModel: ObservableObject { } } - func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) { withAnimation { _updateChatItem(at: i, with: cItem) } + } else if let status = status { + chatItemStatuses.updateValue(status, forKey: cItem.id) } } private func _updateChatItem(at i: Int, with cItem: ChatItem) { - let ci = reversedChatItems[i] reversedChatItems[i] = cItem reversedChatItems[i].viewTimestamp = .now - // on some occasions the confirmation of message being accepted by the server (tick) - // arrives earlier than the response from API, and item remains without tick - if case .sndNew = cItem.meta.itemStatus { - reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus - } } private func getChatItemIndex(_ cItem: ChatItem) -> Int? { @@ -474,6 +475,7 @@ final class ChatModel: ObservableObject { } // clear current chat if chatId == cInfo.id { + chatItemStatuses = [:] reversedChatItems = [] } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2e724bcd83..6fe1bef8ee 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -312,6 +312,7 @@ func loadChat(chat: Chat, search: String = "") { do { let cInfo = chat.chatInfo let m = ChatModel.shared + m.chatItemStatuses = [:] m.reversedChatItems = [] let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) m.updateChatInfo(chat.chatInfo) @@ -1421,11 +1422,8 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .chatItemStatusUpdated(user, aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - if !cItem.isDeletedContent { - let added = active(user) ? await MainActor.run { m.upsertChatItem(cInfo, cItem) } : true - if added && cItem.showNotification { - NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) - } + if !cItem.isDeletedContent && active(user) { + await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) } } if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 81473709cb..21af0ebe17 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -91,6 +91,7 @@ struct ChatView: View { chatModel.chatId = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { + chatModel.chatItemStatuses = [:] chatModel.reversedChatItems = [] } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index f3655d0008..132384219e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -53,6 +53,7 @@ object ChatModel { // current chat val chatId = mutableStateOf(null) val chatItems = mutableStateListOf() + val chatItemStatuses = mutableMapOf() val groupMembers = mutableStateListOf() val terminalItems = mutableStateListOf() @@ -272,7 +273,13 @@ object ChatModel { Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") false } else { - chatItems.add(cItem) + val status = chatItemStatuses.remove(cItem.id) + val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) { + cItem.copy(meta = cItem.meta.copy(itemStatus = status)) + } else { + cItem + } + chatItems.add(ci) Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") true } @@ -282,13 +289,15 @@ object ChatModel { } } - suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) { + suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) { withContext(Dispatchers.Main) { if (chatId.value == cInfo.id) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem } + } else if (status != null) { + chatItemStatuses[cItem.id] = status } } } @@ -326,6 +335,7 @@ object ChatModel { } // clear current chat if (chatId.value == cInfo.id) { + chatItemStatuses.clear() chatItems.clear() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index cc8d481b59..6bd269f24d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1489,11 +1489,8 @@ object ChatController { is CR.ChatItemStatusUpdated -> { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem - if (!cItem.isDeletedContent) { - val added = if (active(r.user)) chatModel.upsertChatItem(cInfo, cItem) else true - if (added && cItem.showNotification) { - ntfManager.notifyMessageReceived(r.user, cInfo, cItem) - } + if (!cItem.isDeletedContent && active(r.user)) { + chatModel.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } } is CR.ChatItemUpdated -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e14089ec52..c76fb73a6b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -73,6 +73,7 @@ fun GroupMemberInfoView( chatModel.addChat(c) } chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() chatModel.chatItems.addAll(c.chatItems) chatModel.chatId.value = c.id closeAll() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 566c981811..b45543df4e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -141,6 +141,7 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { suspend fun openChat(chat: Chat, chatModel: ChatModel) { chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() chatModel.chatItems.addAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 6ad919c27d..be446f6087 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -41,6 +41,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { if (groupInfo != null) { chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id setGroupMembers(groupInfo, chatModel) close.invoke() diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 82499baee6..7e2c2b71e7 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -584,11 +584,11 @@ processChatCommand = \case timed_ <- sndContactCITimed live ct itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ (msg@SndMessage {sharedMsgId}, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) + ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live case ft_ of Just ft@FileTransferMeta {fileInline = Just IFMSent} -> sendDirectFileInline ct ft sharedMsgId _ -> pure () - ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) @@ -649,11 +649,11 @@ processChatCommand = \case timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership (msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) - mapM_ (sendGroupFileInline ms sharedMsgId) ft_ ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live withStore' $ \db -> forM_ sentToMembers $ \GroupMember {groupMemberId} -> createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew + mapM_ (sendGroupFileInline ms sharedMsgId) ft_ forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) @@ -5208,9 +5208,7 @@ deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) msgFlags msgBody let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} - withStoreCtx' - (Just $ "createSndMsgDelivery, sndMsgDelivery: " <> show sndMsgDelivery <> ", msgId: " <> show msgId <> ", cmEventTag: " <> show cmEventTag <> ", msgDeliveryStatus: MDSSndAgent") - $ \db -> createSndMsgDelivery db sndMsgDelivery msgId + withStore' $ \db -> createSndMsgDelivery db sndMsgDelivery msgId sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = From 852e77b1d9ad07ea31f932d83386eb34469f7377 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 18 Oct 2023 16:58:33 +0400 Subject: [PATCH 092/294] android: connect plan (#3242) --- apps/ios/Shared/Model/SimpleXAPI.swift | 1 - .../Onboarding/CreateSimpleXAddress.swift | 2 +- .../chat/simplex/common/model/ChatModel.kt | 1 + .../chat/simplex/common/model/SimpleXAPI.kt | 49 +++ .../simplex/common/views/chat/ChatInfoView.kt | 6 +- .../common/views/chat/group/GroupLinkView.kt | 10 +- .../views/chat/group/GroupMemberInfoView.kt | 50 +-- .../views/chatlist/ChatListNavLinkView.kt | 7 + .../common/views/chatlist/ChatListView.kt | 42 +- .../common/views/newchat/AddContactView.kt | 12 +- .../newchat/ContactConnectionInfoView.kt | 4 +- .../common/views/newchat/PasteToConnect.kt | 23 +- .../simplex/common/views/newchat/QRCode.kt | 23 + .../common/views/newchat/ScanToConnectView.kt | 412 ++++++++++++++++-- .../views/onboarding/CreateSimpleXAddress.kt | 9 +- .../views/usersettings/UserAddressView.kt | 8 +- .../commonMain/resources/MR/base/strings.xml | 29 +- 17 files changed, 512 insertions(+), 176 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6fe1bef8ee..bad15ad52f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -594,7 +594,6 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P } func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { - logger.error("apiConnectPlan connReq: \(connReq)") let userId = try currentUserId("apiConnectPlan") let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan } diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 249180686c..935f09cc1b 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -194,7 +194,7 @@ struct SendAddressMailView: View { let messageBody = String(format: NSLocalizedString("""

Hi!

Connect to me via SimpleX Chat

- """, comment: "email text"), userAddress.connReqContact) + """, comment: "email text"), simplexChatLink(userAddress.connReqContact)) MailView( isShowing: self.$showMailView, result: $mailViewResult, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 132384219e..ad033387ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -136,6 +136,7 @@ object ChatModel { fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } + fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id } fun addChat(chat: Chat) = chats.add(index = 0, chat) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 6bd269f24d..8397d2edb1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -855,6 +855,14 @@ object ChatController { return null } + suspend fun apiConnectPlan(connReq: String): ConnectionPlan? { + val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } + val r = sendCmd(CC.APIConnectPlan(userId, connReq)) + if (r is CR.CRConnectionPlan) return r.connectionPlan + Log.e(TAG, "apiConnectPlan bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnect: no current user") @@ -1914,6 +1922,7 @@ sealed class CC { class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() class APIAddContact(val userId: Long, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() + class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() @@ -2023,6 +2032,7 @@ sealed class CC { is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" + is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" @@ -2124,6 +2134,7 @@ sealed class CC { is APIVerifyGroupMember -> "apiVerifyGroupMember" is APIAddContact -> "apiAddContact" is ApiSetConnectionIncognito -> "apiSetConnectionIncognito" + is APIConnectPlan -> "apiConnectPlan" is APIConnect -> "apiConnect" is ApiDeleteChat -> "apiDeleteChat" is ApiClearChat -> "apiClearChat" @@ -3337,6 +3348,7 @@ sealed class CR { @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() + @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @@ -3472,6 +3484,7 @@ sealed class CR { is ConnectionVerified -> "connectionVerified" is Invitation -> "invitation" is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" + is CRConnectionPlan -> "connectionPlan" is SentConfirmation -> "sentConfirmation" is SentInvitation -> "sentInvitation" is ContactAlreadyExists -> "contactAlreadyExists" @@ -3602,6 +3615,7 @@ sealed class CR { is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is Invitation -> withUser(user, connReqInvitation) is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) + is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) is SentConfirmation -> withUser(user, noDetails()) is SentInvitation -> withUser(user, noDetails()) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) @@ -3715,6 +3729,39 @@ fun chatError(r: CR): ChatErrorType? { ) } +@Serializable +sealed class ConnectionPlan { + @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() + @Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() + @Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() +} + +@Serializable +sealed class InvitationLinkPlan { + @Serializable @SerialName("ok") object Ok: InvitationLinkPlan() + @Serializable @SerialName("ownLink") object OwnLink: InvitationLinkPlan() + @Serializable @SerialName("connecting") class Connecting(val contact_: Contact? = null): InvitationLinkPlan() + @Serializable @SerialName("known") class Known(val contact: Contact): InvitationLinkPlan() +} + +@Serializable +sealed class ContactAddressPlan { + @Serializable @SerialName("ok") object Ok: ContactAddressPlan() + @Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan() + @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan() + @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() + @Serializable @SerialName("known") class Known(val contact: Contact): ContactAddressPlan() +} + +@Serializable +sealed class GroupLinkPlan { + @Serializable @SerialName("ok") object Ok: GroupLinkPlan() + @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() + @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() + @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() + @Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan() +} + abstract class TerminalItem { abstract val id: Long val date: Instant = Clock.System.now() @@ -3874,6 +3921,7 @@ sealed class ChatErrorType { is ChatNotStarted -> "chatNotStarted" is ChatNotStopped -> "chatNotStopped" is ChatStoreChanged -> "chatStoreChanged" + is ConnectionPlanChatError -> "connectionPlan" is InvalidConnReq -> "invalidConnReq" is InvalidChatMessage -> "invalidChatMessage" is ContactNotReady -> "contactNotReady" @@ -3950,6 +3998,7 @@ sealed class ChatErrorType { @Serializable @SerialName("chatNotStarted") object ChatNotStarted: ChatErrorType() @Serializable @SerialName("chatNotStopped") object ChatNotStopped: ChatErrorType() @Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType() + @Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType() @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 1ba742b034..01173157a9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -31,10 +31,10 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.common.views.chatlist.updateChatSettings +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -309,9 +309,9 @@ fun ChatInfoLayout( if (contact.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - QRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) val clipboard = LocalClipboardManager.current - ShareAddressButton { clipboard.shareText(contact.contactLink) } + ShareAddressButton { clipboard.shareText(simplexChatLink(contact.contactLink)) } SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 7c767f9b7e..7e1c03130a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.shareText import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @Composable @@ -44,14 +44,12 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St createLink() } } - val clipboard = LocalClipboardManager.current GroupLinkLayout( groupLink = groupLink, groupInfo, groupLinkMemberRole, creatingLink, createLink = ::createLink, - share = { clipboard.shareText(groupLink ?: return@GroupLinkLayout) }, updateLink = { val role = groupLinkMemberRole.value if (role != null) { @@ -95,7 +93,6 @@ fun GroupLinkLayout( groupLinkMemberRole: MutableState, creatingLink: Boolean, createLink: () -> Unit, - share: () -> Unit, updateLink: () -> Unit, deleteLink: () -> Unit ) { @@ -125,16 +122,17 @@ fun GroupLinkLayout( } initialLaunch = false } - QRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING)) + SimpleXLinkQRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING)) Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) ) { + val clipboard = LocalClipboardManager.current SimpleButton( stringResource(MR.strings.share_link), icon = painterResource(MR.images.ic_share), - click = share + click = { clipboard.shareText(simplexChatLink(groupLink)) } ) SimpleButton( stringResource(MR.strings.delete_link), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index c76fb73a6b..e281c57622 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -284,9 +284,9 @@ fun GroupMemberInfoLayout( if (member.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - QRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) val clipboard = LocalClipboardManager.current - ShareAddressButton { clipboard.shareText(member.contactLink) } + ShareAddressButton { clipboard.shareText(simplexChatLink(member.contactLink)) } if (contactId != null) { if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on) { ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) }) @@ -473,43 +473,17 @@ private fun updateMemberRoleDialog( } fun connectViaMemberAddressAlert(connReqUri: String) { - AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.connect_via_member_address_alert_title), - text = AnnotatedString(generalGetString(MR.strings.connect_via_member_address_alert_desc)), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - withApi { - Log.d(TAG, "connectViaUri: connecting") - connectViaUri(chatModel, linkType, uri, incognito = false) - } - } - }) { - Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - SectionItemView({ - AlertManager.shared.hideAlert() - val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - withApi { - Log.d(TAG, "connectViaUri: connecting incognito") - connectViaUri(chatModel, linkType, uri, incognito = true) - } - } - }) { - Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } + try { + val uri = URI(connReqUri) + withApi { + planAndConnect(chatModel, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) } - ) + } catch (e: RuntimeException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_connection_link), + text = generalGetString(MR.strings.this_string_is_not_a_connection_link) + ) + } } @Preview diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index b45543df4e..b16bf4c9ba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -130,6 +130,13 @@ suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { } } +suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) { + val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId) + if (chat != null) { + openChat(chat, chatModel) + } +} + suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 6d7450a213..66ef1cf9f9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -316,46 +316,8 @@ fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { if (chatModel.currentUser.value == null) { chatModel.appOpenUrl.value = uri } else { - withUriAction(uri) { linkType -> - val title = when (linkType) { - ConnectionLinkType.CONTACT -> generalGetString(MR.strings.connect_via_contact_link) - ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link) - ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link) - } - AlertManager.shared.showAlertDialogButtonsColumn( - title = title, - text = if (linkType == ConnectionLinkType.GROUP) - AnnotatedString(generalGetString(MR.strings.you_will_join_group)) - else - AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - withApi { - Log.d(TAG, "connectIfOpenedViaUri: connecting") - connectViaUri(chatModel, linkType, uri, incognito = false) - } - }) { - Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - SectionItemView({ - AlertManager.shared.hideAlert() - withApi { - Log.d(TAG, "connectIfOpenedViaUri: connecting incognito") - connectViaUri(chatModel, linkType, uri, incognito = true) - } - }) { - Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - } - ) + withApi { + planAndConnect(chatModel, uri, incognito = null, close = null) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index 8542ea52a4..ef3633d1fa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -34,7 +34,6 @@ fun AddContactView( incognitoPref = chatModel.controller.appPrefs.incognito, connReq = connReqInvitation, contactConnection = contactConnection, - share = { clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.center.showModal { Column( @@ -56,7 +55,6 @@ fun AddContactLayout( incognitoPref: SharedPreference, connReq: String, contactConnection: MutableState, - share: () -> Unit, learnMore: () -> Unit ) { val incognito = remember { mutableStateOf(incognitoPref.get()) } @@ -82,7 +80,7 @@ fun AddContactLayout( SectionView(stringResource(MR.strings.one_time_link_short).uppercase()) { if (connReq.isNotEmpty()) { - QRCode( + SimpleXLinkQRCode( connReq, Modifier .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) .aspectRatio(1f) @@ -99,7 +97,7 @@ fun AddContactLayout( } IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } - ShareLinkButton(share) + ShareLinkButton(connReq) OneTimeLinkLearnMoreButton(learnMore) } SectionTextFooter(sharedProfileInfo(chatModel, incognito.value)) @@ -109,11 +107,12 @@ fun AddContactLayout( } @Composable -fun ShareLinkButton(onClick: () -> Unit) { +fun ShareLinkButton(connReqInvitation: String) { + val clipboard = LocalClipboardManager.current SettingsActionItem( painterResource(MR.images.ic_share), stringResource(MR.strings.share_invitation_link), - onClick, + click = { clipboard.shareText(simplexChatLink(connReqInvitation)) }, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, ) @@ -177,7 +176,6 @@ fun PreviewAddContactView() { incognitoPref = SharedPreference({ false }, {}), connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", contactConnection = mutableStateOf(PendingContactConnection.getSampleData()), - share = {}, learnMore = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 934c050d8a..d04a85d905 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -131,13 +131,13 @@ private fun ContactConnectionInfoLayout( SectionView { if (!connReq.isNullOrEmpty() && contactConnection.initiated) { - QRCode( + SimpleXLinkQRCode( connReq, Modifier .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) .aspectRatio(1f) ) incognitoEnabled() - ShareLinkButton(share) + ShareLinkButton(connReq) OneTimeLinkLearnMoreButton(learnMore) } else { incognitoEnabled() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index 0c13bd4f68..f7a5a1e86b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -53,32 +53,14 @@ fun PasteToConnectLayout( fun connectViaLink(connReqUri: String) { try { val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - val action = suspend { - Log.d(TAG, "connectViaUri: connecting") - if (connectViaUri(chatModel, linkType, uri, incognito = incognito.value)) { - close() - } - } - if (linkType == ConnectionLinkType.GROUP) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.connect_via_group_link), - text = generalGetString(MR.strings.you_will_join_group), - confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { action() } } - ) - } else action() + withApi { + planAndConnect(chatModel, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.invalid_connection_link), text = generalGetString(MR.strings.this_string_is_not_a_connection_link) ) - } catch (e: URISyntaxException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_connection_link), - text = generalGetString(MR.strings.this_string_is_not_a_connection_link) - ) } } @@ -115,6 +97,7 @@ fun PasteToConnectLayout( painterResource(MR.images.ic_link), stringResource(MR.strings.connect_button), click = { connectViaLink(connectionLink.value) }, + disabled = connectionLink.value.isEmpty() || connectionLink.value.trim().contains(" ") ) IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 6632925964..763addae66 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -19,6 +19,29 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.launch +@Composable +fun SimpleXLinkQRCode( + connReq: String, + modifier: Modifier = Modifier, + tintColor: Color = Color(0xff062d56), + withLogo: Boolean = true +) { + QRCode( + simplexChatLink(connReq), + modifier, + tintColor, + withLogo + ) +} + +fun simplexChatLink(uri: String): String { + return if (uri.startsWith("simplex:/")) { + uri.replace("simplex:/", "https://simplex.chat/") + } else { + uri + } +} + @Composable fun QRCode( connReq: String, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index e3fa922755..2f52c2cacf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer +import SectionItemView import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview import chat.simplex.common.platform.Log @@ -10,66 +11,265 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.TAG import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.openDirectChat +import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import java.net.URI @Composable expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) enum class ConnectionLinkType { - CONTACT, INVITATION, GROUP + INVITATION, CONTACT, GROUP } -@Serializable -sealed class CReqClientData { - @Serializable @SerialName("group") data class Group(val groupLinkId: String): CReqClientData() -} - -fun withUriAction(uri: URI, run: suspend (ConnectionLinkType) -> Unit) { - val action = uri.path?.drop(1)?.replace("/", "") - val data = URI(uri.toString().replaceFirst("#/", "/")).getQueryParameter("data") - val type = when { - data != null -> { - val parsed = runCatching { - json.decodeFromString(CReqClientData.serializer(), data) +suspend fun planAndConnect( + chatModel: ChatModel, + uri: URI, + incognito: Boolean?, + close: (() -> Unit)? +) { + val connectionPlan = chatModel.controller.apiConnectPlan(uri.toString()) + if (connectionPlan != null) { + when (connectionPlan) { + is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) { + InvitationLinkPlan.Ok -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, incognito=$incognito") + if (incognito != null) { + connectViaUri(chatModel, uri, incognito, connectionPlan, close) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_via_invitation_link), + text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), + connectDestructive = false + ) + } + } + InvitationLinkPlan.OwnLink -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .OwnLink, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), + confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link)), + connectDestructive = true + ) + } + } + is InvitationLinkPlan.Connecting -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .Connecting, incognito=$incognito") + val contact = connectionPlan.invitationLinkPlan.contact_ + if (contact != null) { + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + ) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_already_connecting), + generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link) + ) + } + } + is InvitationLinkPlan.Known -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") + val contact = connectionPlan.invitationLinkPlan.contact + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + ) + } } - when { - parsed.getOrNull() is CReqClientData.Group -> ConnectionLinkType.GROUP - else -> null + is ConnectionPlan.ContactAddress -> when (connectionPlan.contactAddressPlan) { + ContactAddressPlan.Ok -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, incognito=$incognito") + if (incognito != null) { + connectViaUri(chatModel, uri, incognito, connectionPlan, close) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_via_contact_link), + text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), + connectDestructive = false + ) + } + } + ContactAddressPlan.OwnLink -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .OwnLink, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), + confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address)), + connectDestructive = true + ) + } + } + ContactAddressPlan.ConnectingConfirmReconnect -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingConfirmReconnect, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_repeat_connection_request), + text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), + confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_repeat_connection_request), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address)), + connectDestructive = true + ) + } + } + is ContactAddressPlan.ConnectingProhibit -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") + val contact = connectionPlan.contactAddressPlan.contact + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + ) + } + is ContactAddressPlan.Known -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") + val contact = connectionPlan.contactAddressPlan.contact + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + ) + } + } + is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) { + GroupLinkPlan.Ok -> { + Log.d(TAG, "planAndConnect, .GroupLink, .Ok, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_via_group_link), + text = generalGetString(MR.strings.you_will_join_group), + confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } } + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_via_group_link), + text = AnnotatedString(generalGetString(MR.strings.you_will_join_group)), + connectDestructive = false + ) + } + } + is GroupLinkPlan.OwnLink -> { + Log.d(TAG, "planAndConnect, .GroupLink, .OwnLink, incognito=$incognito") + val groupInfo = connectionPlan.groupLinkPlan.groupInfo + ownGroupLinkConfirmConnect(chatModel, uri, incognito, connectionPlan, groupInfo, close) + } + GroupLinkPlan.ConnectingConfirmReconnect -> { + Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_repeat_join_request), + text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), + confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_repeat_join_request), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link)), + connectDestructive = true + ) + } + } + is GroupLinkPlan.ConnectingProhibit -> { + Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingProhibit, incognito=$incognito") + val groupInfo = connectionPlan.groupLinkPlan.groupInfo_ + if (groupInfo != null) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_group_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_vName), groupInfo.displayName) + ) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_already_joining_the_group), + generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + ) + } + } + is GroupLinkPlan.Known -> { + Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") + val groupInfo = connectionPlan.groupLinkPlan.groupInfo + openKnownGroup(chatModel, close, groupInfo) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_group_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName) + ) + } } } - - action == "contact" -> ConnectionLinkType.CONTACT - action == "invitation" -> ConnectionLinkType.INVITATION - else -> null - } - if (type != null) { - withApi { run(type) } } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_contact_link), - text = generalGetString(MR.strings.this_link_is_not_a_valid_connection_link) - ) + Log.d(TAG, "planAndConnect, plan error") + if (incognito != null) { + connectViaUri(chatModel, uri, incognito, connectionPlan = null, close) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan = null, close, + title = generalGetString(MR.strings.connect_plan_connect_via_link), + connectDestructive = false + ) + } } } -suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI, incognito: Boolean): Boolean { +suspend fun connectViaUri( + chatModel: ChatModel, + uri: URI, + incognito: Boolean, + connectionPlan: ConnectionPlan?, + close: (() -> Unit)? +): Boolean { val r = chatModel.controller.apiConnect(incognito, uri.toString()) + val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION if (r) { + close?.invoke() AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = - when (action) { + when (connLinkType) { ConnectionLinkType.CONTACT -> generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) ConnectionLinkType.INVITATION -> generalGetString(MR.strings.you_will_be_connected_when_your_contacts_device_is_online) ConnectionLinkType.GROUP -> generalGetString(MR.strings.you_will_be_connected_when_group_host_device_is_online) @@ -79,6 +279,139 @@ suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: return r } +fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType { + return when(connectionPlan) { + is ConnectionPlan.InvitationLink -> ConnectionLinkType.INVITATION + is ConnectionPlan.ContactAddress -> ConnectionLinkType.CONTACT + is ConnectionPlan.GroupLink -> ConnectionLinkType.GROUP + } +} + +fun askCurrentOrIncognitoProfileAlert( + chatModel: ChatModel, + uri: URI, + connectionPlan: ConnectionPlan?, + close: (() -> Unit)?, + title: String, + text: AnnotatedString? = null, + connectDestructive: Boolean, +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = title, + text = text, + buttons = { + Column { + val connectColor = if (connectDestructive) MaterialTheme.colors.error else MaterialTheme.colors.primary + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +fun openKnownContact(chatModel: ChatModel, close: (() -> Unit)?, contact: Contact) { + withApi { + val c = chatModel.getContactChat(contact.contactId) + if (c != null) { + close?.invoke() + openDirectChat(contact.contactId, chatModel) + } + } +} + +fun ownGroupLinkConfirmConnect( + chatModel: ChatModel, + uri: URI, + incognito: Boolean?, + connectionPlan: ConnectionPlan?, + groupInfo: GroupInfo, + close: (() -> Unit)?, +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_join_your_group), + text = AnnotatedString(String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName)), + buttons = { + Column { + // Open group + SectionItemView({ + AlertManager.shared.hideAlert() + openKnownGroup(chatModel, close, groupInfo) + }) { + Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + if (incognito != null) { + // Join incognito / Join with current profile + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito, connectionPlan, close) + } + }) { + Text( + if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), + Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error + ) + } + } else { + // Use current profile + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Use new incognito profile + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +fun openKnownGroup(chatModel: ChatModel, close: (() -> Unit)?, groupInfo: GroupInfo) { + withApi { + val g = chatModel.getGroupChat(groupInfo.groupId) + if (g != null) { + close?.invoke() + openGroupChat(groupInfo.groupId, chatModel) + } + } +} + @Composable fun ConnectContactLayout( chatModel: ChatModel, @@ -92,21 +425,8 @@ fun ConnectContactLayout( QRCodeScanner { connReqUri -> try { val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - val action = suspend { - Log.d(TAG, "connectViaUri: connecting") - if (connectViaUri(ChatModel, linkType, uri, incognito = incognito.value)) { - close() - } - } - if (linkType == ConnectionLinkType.GROUP) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.connect_via_group_link), - text = generalGetString(MR.strings.you_will_join_group), - confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { action() } } - ) - } else action() + withApi { + planAndConnect(chatModel, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 72cbc3a628..0132223383 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -18,7 +18,8 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.newchat.SimpleXLinkQRCode +import chat.simplex.common.views.newchat.simplexChatLink import chat.simplex.res.MR @Composable @@ -38,7 +39,7 @@ fun CreateSimpleXAddress(m: ChatModel) { sendEmail = { address -> uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), - generalGetString(MR.strings.email_invite_body).format(address.connReqContact) + generalGetString(MR.strings.email_invite_body).format(simplexChatLink(address.connReqContact)) ) }, createAddress = { @@ -91,8 +92,8 @@ private fun CreateSimpleXAddressLayout( Spacer(Modifier.weight(1f)) if (userAddress != null) { - QRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) - ShareAddressButton { share(userAddress.connReqContact) } + SimpleXLinkQRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } Spacer(Modifier.weight(1f)) ShareViaEmailButton { sendEmail(userAddress) } Spacer(Modifier.weight(1f)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 63f06a2aec..d03b758565 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -24,10 +24,10 @@ import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ShareAddressButton import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.model.ChatModel import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.* +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @Composable @@ -100,7 +100,7 @@ fun UserAddressView( sendEmail = { userAddress -> uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), - generalGetString(MR.strings.email_invite_body).format(userAddress.connReqContact) + generalGetString(MR.strings.email_invite_body).format(simplexChatLink( userAddress.connReqContact)) ) }, setProfileAddress = ::setProfileAddress, @@ -201,8 +201,8 @@ private fun UserAddressLayout( val autoAcceptState = remember { mutableStateOf(AutoAcceptState(userAddress)) } val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) } SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - QRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) - ShareAddressButton { share(userAddress.connReqContact) } + SimpleXLinkQRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } ShareViaEmailButton { sendEmail(userAddress) } ShareWithContactsButton(shareViaProfile, setProfileAddress) AutoAcceptToggle(autoAcceptState) { saveAas(autoAcceptState.value, autoAcceptStateSaved) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 114fe49e92..bd59d236d5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -4,13 +4,13 @@ k - Connect via contact link? - Connect via invitation link? - Connect via group link? + Connect via contact address? + Connect via one-time link? + Join group? Use current profile Use new incognito profile Your profile will be sent to the contact that you received this link from. - You will join a group this link refers to and connect to its group members. + You will connect to all group members. Connect Connect incognito @@ -1600,4 +1600,25 @@ Coming soon! This feature is not yet supported. Try the next release. + + + Connect to yourself? + This is your own one-time link! + You are already connecting to %1$s. + Already connecting! + You are already connecting via this one-time link! + This is your own SimpleX address! + Repeat connection request? + You have already requested connection via this address! + Join your group? + This is your link for group %1$s! + Open group + Repeat join request? + You are already joining the group via this link! + Group already exists! + You are already joining the group %1$s. + Already joining the group! + You are already joining the group via this link. + You are already in group %1$s. + Connect via link? \ No newline at end of file From 2219cea0268ca13eff56978c9898d515cbdcaf31 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 18 Oct 2023 21:15:37 +0100 Subject: [PATCH 093/294] core: fix type for JSON --- src/Simplex/Chat/Messages.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 21da83ad05..2ddb1e7bcd 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -933,7 +933,7 @@ instance ToJSON (CIDeleted d) where data JSONCIDeleted = JCIDDeleted {deletedTs :: Maybe UTCTime} - | JCIBlocked {deletedTs :: Maybe UTCTime} + | JCIDBlocked {deletedTs :: Maybe UTCTime} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} deriving (Show, Generic) @@ -944,7 +944,7 @@ instance ToJSON JSONCIDeleted where jsonCIDeleted :: CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case CIDeleted ts -> JCIDDeleted ts - CIBlocked ts -> JCIBlocked ts + CIBlocked ts -> JCIDBlocked ts CIModerated ts m -> JCIDModerated ts m itemDeletedTs :: CIDeleted d -> Maybe UTCTime From c090b68bdda6073aeca7ac94dfde8b654eec5aba Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 19 Oct 2023 19:52:59 +0400 Subject: [PATCH 094/294] ios, android: ask to notify contact or not on contact deletion (#3247) --- apps/ios/Shared/Model/SimpleXAPI.swift | 8 +-- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 65 +++++++++-------- .../Views/ChatList/ChatListNavLink.swift | 42 ++++++----- apps/ios/SimpleXChat/APITypes.swift | 8 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 12 ++-- .../simplex/common/views/chat/ChatInfoView.kt | 69 +++++++++++++++---- .../commonMain/resources/MR/base/strings.xml | 1 + 7 files changed, 137 insertions(+), 68 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index bad15ad52f..99e8c02847 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -671,18 +671,18 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert { } } -func apiDeleteChat(type: ChatType, id: Int64) async throws { - let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false) +func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws { + let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false) if case .direct = type, case .contactDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .group = type, case .groupDeletedUser = r { return } throw r } -func deleteChat(_ chat: Chat) async { +func deleteChat(_ chat: Chat, notify: Bool? = nil) async { do { let cInfo = chat.chatInfo - try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId) + try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify) DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) } } catch let error { logger.error("deleteChat apiDeleteChat error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 5438eb13bc..ec4cc0fc4c 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -99,12 +99,12 @@ struct ChatInfoView: View { @Binding var connectionCode: String? @FocusState private var aliasTextFieldFocused: Bool @State private var alert: ChatInfoViewAlert? = nil + @State private var showDeleteContactActionSheet = false @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum ChatInfoViewAlert: Identifiable { - case deleteContactAlert case clearChatAlert case networkStatusAlert case switchAddressAlert @@ -114,7 +114,6 @@ struct ChatInfoView: View { var id: String { switch self { - case .deleteContactAlert: return "deleteContactAlert" case .clearChatAlert: return "clearChatAlert" case .networkStatusAlert: return "networkStatusAlert" case .switchAddressAlert: return "switchAddressAlert" @@ -233,7 +232,6 @@ struct ChatInfoView: View { } .alert(item: $alert) { alertItem in switch(alertItem) { - case .deleteContactAlert: return deleteContactAlert() case .clearChatAlert: return clearChatAlert() case .networkStatusAlert: return networkStatusAlert() case .switchAddressAlert: return switchAddressAlert(switchContactAddress) @@ -242,6 +240,26 @@ struct ChatInfoView: View { case let .error(title, error): return mkAlert(title: title, message: error) } } + .actionSheet(isPresented: $showDeleteContactActionSheet) { + if contact.ready && contact.active { + ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete and notify contact")) { deleteContact(notify: true) }, + .destructive(Text("Delete")) { deleteContact(notify: false) }, + .cancel() + ] + ) + } else { + ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete")) { deleteContact() }, + .cancel() + ] + ) + } + } } private func contactInfoHeader() -> some View { @@ -414,7 +432,7 @@ struct ChatInfoView: View { private func deleteContactButton() -> some View { Button(role: .destructive) { - alert = .deleteContactAlert + showDeleteContactActionSheet = true } label: { Label("Delete contact", systemImage: "trash") .foregroundColor(Color.red) @@ -430,30 +448,23 @@ struct ChatInfoView: View { } } - private func deleteContactAlert() -> Alert { - Alert( - title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted - this cannot be undone!"), - primaryButton: .destructive(Text("Delete")) { - Task { - do { - try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) - await MainActor.run { - dismiss() - chatModel.chatId = nil - chatModel.removeChat(chat.chatInfo.id) - } - } catch let error { - logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))") - let a = getErrorAlert(error, "Error deleting contact") - await MainActor.run { - alert = .error(title: a.title, error: a.message) - } - } + private func deleteContact(notify: Bool? = nil) { + Task { + do { + try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify) + await MainActor.run { + dismiss() + chatModel.chatId = nil + chatModel.removeChat(chat.chatInfo.id) } - }, - secondaryButton: .cancel() - ) + } catch let error { + logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))") + let a = getErrorAlert(error, "Error deleting contact") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } } private func clearChatAlert() -> Alert { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index f445ae4b5d..be912d666a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -32,6 +32,7 @@ struct ChatListNavLink: View { @State private var showJoinGroupDialog = false @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false + @State private var showDeleteContactActionSheet = false var body: some View { switch chat.chatInfo { @@ -64,17 +65,37 @@ struct ChatListNavLink: View { clearChatButton() } Button { - AlertManager.shared.showAlert( - contact.ready || !contact.active - ? deleteContactAlert(chat.chatInfo) - : deletePendingContactAlert(chat, contact) - ) + if contact.ready || !contact.active { + showDeleteContactActionSheet = true + } else { + AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact)) + } } label: { Label("Delete", systemImage: "trash") } .tint(.red) } .frame(height: rowHeights[dynamicTypeSize]) + .actionSheet(isPresented: $showDeleteContactActionSheet) { + if contact.ready && contact.active { + ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } }, + .destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } }, + .cancel() + ] + ) + } else { + ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, + .cancel() + ] + ) + } + } } @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { @@ -269,17 +290,6 @@ struct ChatListNavLink: View { } } - private func deleteContactAlert(_ chatInfo: ChatInfo) -> Alert { - Alert( - title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted - this cannot be undone!"), - primaryButton: .destructive(Text("Delete")) { - Task { await deleteChat(chat) } - }, - secondaryButton: .cancel() - ) - } - private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { Alert( title: Text("Delete group?"), diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 53da91b042..5c7220f374 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -89,7 +89,7 @@ public enum ChatCommand { case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiConnectPlan(userId: Int64, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String) - case apiDeleteChat(type: ChatType, id: Int64) + case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?) case apiClearChat(type: ChatType, id: Int64) case apiListContacts(userId: Int64) case apiUpdateProfile(userId: Int64, profile: Profile) @@ -224,7 +224,11 @@ public enum ChatCommand { case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" - case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" + case let .apiDeleteChat(type, id, notify): if let notify = notify { + return "/_delete \(ref(type, id)) notify=\(onOff(notify))" + } else { + return "/_delete \(ref(type, id))" + } case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiListContacts(userId): return "/_contacts \(userId)" case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 8397d2edb1..da09ea132f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -904,8 +904,8 @@ object ChatController { } } - suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean { - val r = sendCmd(CC.ApiDeleteChat(type, id)) + suspend fun apiDeleteChat(type: ChatType, id: Long, notify: Boolean? = null): Boolean { + val r = sendCmd(CC.ApiDeleteChat(type, id, notify)) when { r is CR.ContactDeleted && type == ChatType.Direct -> return true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true @@ -1924,7 +1924,7 @@ sealed class CC { class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() - class ApiDeleteChat(val type: ChatType, val id: Long): CC() + class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() class ApiListContacts(val userId: Long): CC() class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() @@ -2034,7 +2034,11 @@ sealed class CC { is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" - is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" + is ApiDeleteChat -> if (notify != null) { + "/_delete ${chatRef(type, id)} notify=${onOff(notify)}" + } else { + "/_delete ${chatRef(type, id)}" + } is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" is ApiListContacts -> "/_contacts $userId" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 01173157a9..4564a4a6e8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -196,28 +196,67 @@ sealed class SendReceipts { } fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { - AlertManager.shared.showAlertDialog( + AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), - text = generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning), - confirmText = generalGetString(MR.strings.delete_verb), - onConfirm = { - withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) - if (r) { - chatModel.removeChat(chatInfo.id) - if (chatModel.chatId.value == chatInfo.id) { - chatModel.chatId.value = null - ModalManager.end.closeModals() + text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)), + buttons = { + Column { + if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) { + // Delete and notify contact + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + deleteContact(chatInfo, chatModel, close, notify = true) + } + }) { + Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } - ntfManager.cancelNotificationsForChat(chatInfo.id) - close?.invoke() + // Delete + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + deleteContact(chatInfo, chatModel, close, notify = false) + } + }) { + Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } else { + // Delete + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + deleteContact(chatInfo, chatModel, close) + } + }) { + Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - }, - destructive = true, + } ) } +fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { + withApi { + val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId, notify) + if (r) { + chatModel.removeChat(chatInfo.id) + if (chatModel.chatId.value == chatInfo.id) { + chatModel.chatId.value = null + ModalManager.end.closeModals() + } + ntfManager.cancelNotificationsForChat(chatInfo.id) + close?.invoke() + } + } +} + fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.clear_chat_question), diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index bd59d236d5..1ae85cd050 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -350,6 +350,7 @@ Delete contact? Contact and all messages will be deleted - this cannot be undone! + Delete and notify contact Delete contact Set contact name… Connected From 87d84cfccce5007cf404898b20d2142d451f9b1c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 21 Oct 2023 19:13:32 +0400 Subject: [PATCH 095/294] core: filter connection plan query results by user_id (#3251) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 2 +- .../Chat/Migrations/M20231019_indexes.hs | 32 +++++++++++++++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 13 +++++--- src/Simplex/Chat/Store/Connections.hs | 10 +++--- src/Simplex/Chat/Store/Direct.hs | 2 +- src/Simplex/Chat/Store/Groups.hs | 6 ++-- src/Simplex/Chat/Store/Migrations.hs | 4 ++- src/Simplex/Chat/Store/Profiles.hs | 8 ++--- tests/SchemaDump.hs | 4 ++- 10 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231019_indexes.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b431b0ddf3..824b558135 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -117,6 +117,7 @@ library Simplex.Chat.Migrations.M20231002_conn_initiated Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash Simplex.Chat.Migrations.M20231010_member_settings + Simplex.Chat.Migrations.M20231019_indexes Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 7e2c2b71e7..33dd262b01 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2249,7 +2249,7 @@ processChatCommand = \case case groupLinkId of -- contact address Nothing -> - withStore' (`getUserContactLinkByConnReq` cReqSchemas) >>= \case + withStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case Just _ -> pure $ CPContactAddress CAPOwnLink Nothing -> do withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHashes) >>= \case diff --git a/src/Simplex/Chat/Migrations/M20231019_indexes.hs b/src/Simplex/Chat/Migrations/M20231019_indexes.hs new file mode 100644 index 0000000000..40412e1778 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231019_indexes.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231019_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231019_indexes :: Query +m20231019_indexes = + [sql| +DROP INDEX idx_connections_conn_req_inv; +CREATE INDEX idx_connections_conn_req_inv ON connections(user_id, conn_req_inv); + +DROP INDEX idx_groups_via_group_link_uri_hash; +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups(user_id, via_group_link_uri_hash); + +DROP INDEX idx_connections_via_contact_uri_hash; +CREATE INDEX idx_connections_via_contact_uri_hash ON connections(user_id, via_contact_uri_hash); +|] + +down_m20231019_indexes :: Query +down_m20231019_indexes = + [sql| +DROP INDEX idx_connections_conn_req_inv; +CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); + +DROP INDEX idx_groups_via_group_link_uri_hash; +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups(via_group_link_uri_hash); + +DROP INDEX idx_connections_via_contact_uri_hash; +CREATE INDEX idx_connections_via_contact_uri_hash ON connections(via_contact_uri_hash); +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 7308ef89ff..cadb7caf42 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -524,9 +524,6 @@ CREATE INDEX contact_profiles_index ON contact_profiles( full_name ); CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); -CREATE INDEX idx_connections_via_contact_uri_hash ON connections( - via_contact_uri_hash -); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); @@ -738,7 +735,15 @@ CREATE INDEX idx_received_probes_probe_hash ON received_probes(probe_hash); CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); -CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); +CREATE INDEX idx_connections_conn_req_inv ON connections( + user_id, + conn_req_inv +); CREATE INDEX idx_groups_via_group_link_uri_hash ON groups( + user_id, via_group_link_uri_hash ); +CREATE INDEX idx_connections_via_contact_uri_hash ON connections( + user_id, + via_contact_uri_hash +); diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 59ffb57c6e..d73ac705d3 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -155,9 +155,9 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do userContact_ _ = Left SEUserContactLinkNotFound getConnectionEntityByConnReq :: DB.Connection -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) -getConnectionEntityByConnReq db user (cReqSchema1, cReqSchema2) = do +getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do connId_ <- maybeFirstRow fromOnly $ - DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv IN (?,?) LIMIT 1" (cReqSchema1, cReqSchema2) + DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ -- search connection for connection plan: @@ -165,7 +165,7 @@ getConnectionEntityByConnReq db user (cReqSchema1, cReqSchema2) = do -- this function searches for latest connection with contact so that "known contact" plan would be chosen; -- deleted connections are filtered out to allow re-connecting via same contact address getContactConnEntityByConnReqHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) -getContactConnEntityByConnReqHash db user (cReqHash1, cReqHash2) = do +getContactConnEntityByConnReqHash db user@User {userId} (cReqHash1, cReqHash2) = do connId_ <- maybeFirstRow fromOnly $ DB.query db @@ -175,12 +175,12 @@ getContactConnEntityByConnReqHash db user (cReqHash1, cReqHash2) = do agent_conn_id, (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord FROM connections - WHERE via_contact_uri_hash IN (?,?) AND conn_status != ? + WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? ORDER BY conn_ord DESC, created_at DESC LIMIT 1 ) |] - (cReqHash1, cReqHash2, ConnDeleted) + (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7227797193..563cc337e0 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -167,7 +167,7 @@ getContactByConnReqHash db user@User {userId} cReqHash = FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 ORDER BY c.created_at DESC LIMIT 1 |] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a4a19816da..76d68cc6be 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1122,16 +1122,16 @@ getGroupInfo db User {userId, userContactId} groupId = (groupId, userId, userContactId) getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) -getGroupInfoByUserContactLinkConnReq db user (cReqSchema1, cReqSchema2) = do +getGroupInfoByUserContactLinkConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do groupId_ <- maybeFirstRow fromOnly $ DB.query db [sql| SELECT group_id FROM user_contact_links - WHERE conn_req_contact IN (?,?) + WHERE user_id = ? AND conn_req_contact IN (?,?) |] - (cReqSchema1, cReqSchema2) + (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ getGroupInfoByGroupLinkHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 60783f3664..357bfd9a28 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -85,6 +85,7 @@ import Simplex.Chat.Migrations.M20230926_contact_status import Simplex.Chat.Migrations.M20231002_conn_initiated import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash import Simplex.Chat.Migrations.M20231010_member_settings +import Simplex.Chat.Migrations.M20231019_indexes import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -169,7 +170,8 @@ schemaMigrations = ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated), ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash), - ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings) + ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings), + ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index c9c4806496..80499dee82 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -441,17 +441,17 @@ getUserContactLinkById db userId userContactLinkId = |] (userId, userContactLinkId) -getUserContactLinkByConnReq :: DB.Connection -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) -getUserContactLinkByConnReq db (cReqSchema1, cReqSchema2) = +getUserContactLinkByConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) +getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = maybeFirstRow toUserContactLink $ DB.query db [sql| SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links - WHERE conn_req_contact IN (?,?) + WHERE user_id = ? AND conn_req_contact IN (?,?) |] - (cReqSchema1, cReqSchema2) + (userId, cReqSchema1, cReqSchema2) updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept db user@User {userId} autoAccept = do diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index f4538e4b3b..f517d13df1 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -71,7 +71,9 @@ skipComparisonForDownMigrations = -- on down migration idx_chat_items_timed_delete_at index moves down to the end of the file "20230529_indexes", -- table and index definitions move down the file, so fields are re-created as not unique - "20230914_member_probes" + "20230914_member_probes", + -- on down migration idx_connections_via_contact_uri_hash index moves down to the end of the file + "20231019_indexes" ] getSchema :: FilePath -> FilePath -> IO String From 0d1a080a6e299ea34ece5445ac6b1484c6eaf541 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Sun, 22 Oct 2023 11:42:19 +0300 Subject: [PATCH 096/294] remote protocol (#3225) * draft remote protocol types and external api * types (it compiles) * add error * move remote controller from http to remote host client protocol * refactor (doesnt compile) * fix compile * Connect remote session * WIP: wire in remote protocol * add commands and events * cleanup * fix desktop shutdown * prepare for testing remote files * Add file IO * update simplexmq to master with http2 to 4.1.4 * use json transcoder * update simplexmq * collapse RemoteHostSession states * fold RemoteHello back into the protocol command move http-command-response-http wrapper to protocol * use sendRemoteCommand with optional attachments use streaming request/response * ditch lazy body streaming * fix formatting * put body builder/processor closer together * wrap handleRemoteCommand around sending files * handle ChatError's too * remove binary, use 32-bit encoding for JSON bodies * enable tests * refactor * refactor request handling * return ChatError * Flatten remote host --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 4 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 4 +- simplex-chat.cabal | 15 +- src/Simplex/Chat.hs | 12 +- src/Simplex/Chat/Controller.hs | 13 +- src/Simplex/Chat/Remote.hs | 515 ++++++++++----------------- src/Simplex/Chat/Remote/Discovery.hs | 61 +++- src/Simplex/Chat/Remote/Protocol.hs | 199 +++++++++++ src/Simplex/Chat/Remote/Types.hs | 70 +++- stack.yaml | 2 +- tests/JSONTests.hs | 2 +- tests/RemoteTests.hs | 328 +++++++++-------- tests/Test.hs | 2 +- tests/ViewTests.hs | 1 - 15 files changed, 693 insertions(+), 537 deletions(-) create mode 100644 src/Simplex/Chat/Remote/Protocol.hs diff --git a/cabal.project b/cabal.project index 9a9a3e25da..aebe18ae91 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 1ad69cf74f18f25713ce564e1629d2538313b9e0 + tag: deb3fc73595ceae34902d3402d075e3a531d5221 source-repository-package type: git @@ -19,7 +19,7 @@ source-repository-package source-repository-package type: git location: https://github.com/kazu-yamamoto/http2.git - tag: b5a1b7200cf5bc7044af34ba325284271f6dff25 + tag: 804fa283f067bd3fd89b8c5f8d25b3047813a517 source-repository-package type: git diff --git a/package.yaml b/package.yaml index df2624ac83..23fefc7ea3 100644 --- a/package.yaml +++ b/package.yaml @@ -19,7 +19,6 @@ dependencies: - attoparsec == 0.14.* - base >= 4.7 && < 5 - base64-bytestring >= 1.0 && < 1.3 - - binary >= 0.8 && < 0.9 - bytestring == 0.11.* - composition == 1.0.* - constraints >= 0.12 && < 0.14 @@ -36,6 +35,7 @@ dependencies: - memory == 0.18.* - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 + - network-transport == 0.5.6 - network-udp >= 0.0 && < 0.1 - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 17d650cb09..6d5e1b9e7b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,7 @@ { - "https://github.com/simplex-chat/simplexmq.git"."1ad69cf74f18f25713ce564e1629d2538313b9e0" = "1kil0962pn3ksnxh7dcwcbnkidz95yl31rm4m585ps7wnh6fp0l9"; + "https://github.com/simplex-chat/simplexmq.git"."deb3fc73595ceae34902d3402d075e3a531d5221" = "031zrk32p8ji8hlvk8aj1v99g5zpcsran8qhq36sgi34sy6864z6"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; - "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; + "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 512f1427c9..2260f46e49 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -127,6 +127,7 @@ library Simplex.Chat.Protocol Simplex.Chat.Remote Simplex.Chat.Remote.Discovery + Simplex.Chat.Remote.Protocol Simplex.Chat.Remote.Types Simplex.Chat.Store Simplex.Chat.Store.Connections @@ -160,7 +161,6 @@ library , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -177,6 +177,7 @@ library , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -213,7 +214,6 @@ executable simplex-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -230,6 +230,7 @@ executable simplex-bot , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -267,7 +268,6 @@ executable simplex-bot-advanced , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -284,6 +284,7 @@ executable simplex-bot-advanced , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -323,7 +324,6 @@ executable simplex-broadcast-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -340,6 +340,7 @@ executable simplex-broadcast-bot , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -378,7 +379,6 @@ executable simplex-chat , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -395,6 +395,7 @@ executable simplex-chat , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -437,7 +438,6 @@ executable simplex-directory-service , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -454,6 +454,7 @@ executable simplex-directory-service , memory ==0.18.* , mtl ==2.3.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* @@ -519,7 +520,6 @@ test-suite simplex-chat-test , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , binary ==0.8.* , bytestring ==0.11.* , composition ==1.0.* , constraints >=0.12 && <0.14 @@ -539,6 +539,7 @@ test-suite simplex-chat-test , memory ==0.18.* , mtl ==2.3.* , network ==3.1.* + , network-transport ==0.5.6 , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 87a8fd3569..a491561cca 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -109,6 +109,7 @@ import System.Random (randomRIO) import Text.Read (readMaybe) import UnliftIO.Async import UnliftIO.Concurrent (forkFinally, forkIO, mkWeakThreadId, threadDelay) +import qualified UnliftIO.Exception as E import UnliftIO.Directory import UnliftIO.IO (hClose, hSeek, hTell, openFile) import UnliftIO.STM @@ -389,17 +390,20 @@ execChatCommand rh s = do case parseChatCommand s of Left e -> pure $ chatCmdError u e Right cmd -> case rh of - Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId (s, cmd) + Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId s _ -> execChatCommand_ u cmd execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse -execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) +execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd -execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> (ByteString, ChatCommand) -> m ChatResponse -execRemoteCommand u rhId scmd = either (CRChatCmdError u) id <$> runExceptT (getRemoteHostSession rhId >>= (`processRemoteCommand` scmd)) +execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ByteString -> m ChatResponse +execRemoteCommand u rhId s = handleCommandError u $ getRemoteHostSession rhId >>= \rh -> processRemoteCommand rhId rh s + +handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse +handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catch` (pure . Left . mkChatError)) parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 276bc69c3e..d2179b1a95 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -72,7 +72,6 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), Cor import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import System.IO (Handle) @@ -1153,6 +1152,7 @@ data RemoteHostError | RHTimeout -- ^ A discovery or a remote operation has timed out | RHDisconnected {reason :: Text} -- ^ A session disconnected by a host | RHConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues + | RHProtocolError RemoteProtocolError deriving (Show, Exception, Generic) instance FromJSON RemoteHostError where @@ -1175,6 +1175,7 @@ data RemoteCtrlError | RCEHTTP2Error {http2Error :: String} | RCEHTTP2RespStatus {statusCode :: Maybe Int} -- TODO remove | RCEInvalidResponse {responseError :: String} + | RCEProtocolError {protocolError :: RemoteProtocolError} deriving (Show, Exception, Generic) instance FromJSON RemoteCtrlError where @@ -1196,16 +1197,6 @@ instance ToJSON ArchiveError where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" -data RemoteHostSession - = RemoteHostSessionStarting - { announcer :: Async () - } - | RemoteHostSessionStarted - { -- | Path for local resources to be synchronized with host - storePath :: FilePath, - ctrlClient :: HTTP2Client - } - data RemoteCtrlSession = RemoteCtrlSession { -- | Host (mobile) side of transport to process remote commands and forward notifications discoverer :: Async (), diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 336b5d2cf9..c195b4631d 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -5,7 +5,6 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -20,148 +19,136 @@ import Control.Monad.IO.Class import Control.Monad.Reader (asks) import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) -import Data.Aeson ((.=)) import qualified Data.Aeson as J -import qualified Data.Aeson.Key as JK -import qualified Data.Aeson.KeyMap as JM -import qualified Data.Attoparsec.ByteString.Char8 as A -import qualified Data.Binary.Builder as Binary -import Data.ByteString (ByteString, hPut) +import Data.ByteString (ByteString) import qualified Data.ByteString.Base64.URL as B64U +import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B -import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (decodeUtf8, encodeUtf8) -import qualified Network.HTTP.Types as HTTP -import qualified Network.HTTP.Types.Status as Status -import qualified Network.HTTP2.Client as HC -import qualified Network.HTTP2.Server as HS +import Data.Text.Encoding (encodeUtf8) +import Data.Word (Word32) +import Network.HTTP2.Server (responseStreaming) +import qualified Network.HTTP.Types as N import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Controller -import Simplex.Chat.Messages (AChatItem (..), CIFile (..), CIFileStatus (..), ChatItem (..), chatNameStr) -import Simplex.Chat.Messages.CIContent (MsgDirection (..), SMsgDirection (..)) import qualified Simplex.Chat.Remote.Discovery as Discovery +import Simplex.Chat.Remote.Protocol import Simplex.Chat.Remote.Types -import Simplex.Chat.Store.Files (getRcvFileTransfer) -import Simplex.Chat.Store.Profiles (getUser) import Simplex.Chat.Store.Remote -import Simplex.Chat.Store.Shared (StoreError (..)) -import Simplex.Chat.Types -import Simplex.FileTransfer.Util (uniqueCombine) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) -import Simplex.Messaging.Parsers (pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) -import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), defaultHTTP2BufferSize) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, HTTP2Response (..)) -import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2 -import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2 -import Simplex.Messaging.Util (bshow, ifM, liftEitherError, liftEitherWith, tshow, ($>>=)) -import System.FilePath (isPathSeparator, takeFileName, ()) +import Simplex.Messaging.Transport.HTTP2.File (hSendFile) +import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=)) +import System.FilePath (()) import UnliftIO -import UnliftIO.Directory (createDirectoryIfMissing, getFileSize) + +-- * Desktop side getRemoteHostSession :: ChatMonad m => RemoteHostId -> m RemoteHostSession -getRemoteHostSession rhId = chatReadVar remoteHostSessions >>= maybe err pure . M.lookup rhId - where - err = throwError $ ChatErrorRemoteHost rhId RHMissing +getRemoteHostSession rhId = withRemoteHostSession rhId $ \_ s -> pure $ Right s -checkNoRemoteHostSession :: ChatMonad m => RemoteHostId -> m () -checkNoRemoteHostSession rhId = chatReadVar remoteHostSessions >>= maybe (pure ()) err . M.lookup rhId +withRemoteHostSession :: ChatMonad m => RemoteHostId -> (TM.TMap RemoteHostId RemoteHostSession -> RemoteHostSession -> STM (Either ChatError a)) -> m a +withRemoteHostSession rhId = withRemoteHostSession_ rhId missing where - err _ = throwError $ ChatErrorRemoteHost rhId RHBusy + missing _ = pure . Left $ ChatErrorRemoteHost rhId RHMissing + +withNoRemoteHostSession :: ChatMonad m => RemoteHostId -> (TM.TMap RemoteHostId RemoteHostSession -> STM (Either ChatError a)) -> m a +withNoRemoteHostSession rhId action = withRemoteHostSession_ rhId action busy + where + busy _ _ = pure . Left $ ChatErrorRemoteHost rhId RHBusy + +-- | Atomically process controller state wrt. specific remote host session +withRemoteHostSession_ :: ChatMonad m => RemoteHostId -> (TM.TMap RemoteHostId RemoteHostSession -> STM (Either ChatError a)) -> (TM.TMap RemoteHostId RemoteHostSession -> RemoteHostSession -> STM (Either ChatError a)) -> m a +withRemoteHostSession_ rhId missing present = do + sessions <- asks remoteHostSessions + liftIOEither . atomically $ TM.lookup rhId sessions >>= maybe (missing sessions) (present sessions) startRemoteHost :: ChatMonad m => RemoteHostId -> m () startRemoteHost rhId = do - checkNoRemoteHostSession rhId rh <- withStore (`getRemoteHost` rhId) - announcer <- async $ do - finished <- newTVarIO False - http <- start rh finished `onChatError` cleanup finished - run rh finished http - chatModifyVar remoteHostSessions $ M.insert rhId RemoteHostSessionStarting {announcer} + tasks <- startRemoteHostSession rh + logInfo $ "Remote host session starting for " <> tshow rhId + asyncRegistered tasks $ run rh tasks `catchAny` \err -> do + logError $ "Remote host session startup failed for " <> tshow rhId <> ": " <> tshow err + cancelTasks tasks + chatModifyVar remoteHostSessions $ M.delete rhId + throwError $ fromMaybe (mkChatError err) $ fromException err + -- logInfo $ "Remote host session starting for " <> tshow rhId where - cleanup finished = do - logInfo "Remote host http2 client fininshed" - atomically $ writeTVar finished True - -- TODO why this is not an error? - M.lookup rhId <$> chatReadVar remoteHostSessions >>= \case - Nothing -> logInfo $ "Session already closed for remote host " <> tshow rhId - Just _ -> closeRemoteHostSession rhId >> toView (CRRemoteHostStopped rhId) - start rh@RemoteHost {storePath, caKey, caCert} finished = do - let parent = (C.signatureKeyPair caKey, caCert) - sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session" - let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent] - u <- askUnliftIO - ctrlClient <- liftHTTP2 $ Discovery.announceRevHTTP2 fingerprint credentials $ unliftIO u (cleanup finished) -- >>= \case - chatModifyVar remoteHostSessions $ M.insert rhId RemoteHostSessionStarted {storePath, ctrlClient} - chatWriteVar currentRemoteHost $ Just rhId - HTTP2Response {respBody = HTTP2Body {bodyHead}} <- sendHello ctrlClient + run :: ChatMonad m => RemoteHost -> Tasks -> m () + run rh@RemoteHost {storePath} tasks = do + (fingerprint, credentials) <- liftIO $ genSessionCredentials rh + cleanupIO <- toIO $ do + logNote $ "Remote host session stopping for " <> tshow rhId + cancelTasks tasks -- cancel our tasks anyway + chatModifyVar currentRemoteHost $ \cur -> if cur == Just rhId then Nothing else cur -- only wipe the closing RH + withRemoteHostSession rhId $ \sessions _ -> Right <$> TM.delete rhId sessions + toView (CRRemoteHostStopped rhId) -- only signal "stopped" when the session is unregistered cleanly + -- block until some client is connected or an error happens + logInfo $ "Remote host session connecting for " <> tshow rhId + httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ Discovery.announceRevHTTP2 tasks fingerprint credentials cleanupIO + logInfo $ "Remote host session connected for " <> tshow rhId rcName <- chatReadVar localDeviceName - -- TODO what sets session active? - toView CRRemoteHostConnected {remoteHost = remoteHostInfo rh True rcName} - pure ctrlClient - run RemoteHost {storePath} finished ctrlClient = do + -- test connection and establish a protocol layer + remoteHostClient <- liftRH rhId $ createRemoteHostClient httpClient rcName + -- set up message polling oq <- asks outputQ - let toViewRemote = atomically . writeTBQueue oq . (Nothing,Just rhId,) - -- TODO remove REST - void . async $ pollRemote finished ctrlClient "/recv" $ handleFile >=> toViewRemote + asyncRegistered tasks . forever $ do + liftRH rhId (remoteRecv remoteHostClient 1000000) >>= mapM_ (atomically . writeTBQueue oq . (Nothing,Just rhId,)) + -- update session state + logInfo $ "Remote host session started for " <> tshow rhId + chatModifyVar remoteHostSessions $ M.adjust (\rhs -> rhs {remoteHostClient = Just remoteHostClient}) rhId + chatWriteVar currentRemoteHost $ Just rhId + toView $ CRRemoteHostConnected RemoteHostInfo + { remoteHostId = rhId, + storePath = storePath, + displayName = remoteDeviceName remoteHostClient, + remoteCtrlOOB = RemoteCtrlOOB {fingerprint, displayName=rcName}, + sessionActive = True + } + + genSessionCredentials RemoteHost {caKey, caCert} = do + sessionCreds <- genCredentials (Just parent) (0, 24) "Session" + pure . tlsCredentials $ sessionCreds :| [parent] where - -- TODO move to view / terminal - handleFile = \case - cr@CRRcvFileComplete {user, chatItem = AChatItem c SMDRcv i ci@ChatItem {file = Just ciFile@CIFile {fileStatus = CIFSRcvComplete}}} -> do - maybe cr update <$> handleRcvFileComplete ctrlClient storePath user ciFile - where - update localFile = cr {chatItem = AChatItem c SMDRcv i ci {file = Just localFile}} - cr -> pure cr + parent = (C.signatureKeyPair caKey, caCert) -sendHello :: ChatMonad m => HTTP2Client -> m HTTP2Response -sendHello http = liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing - where - req = HC.requestNoBody "GET" "/" mempty - --- TODO how (on what condition) it would stop polling? --- TODO add JSON translation -pollRemote :: ChatMonad m => TVar Bool -> HTTP2Client -> ByteString -> (ChatResponse -> m ()) -> m () -pollRemote finished http path action = loop `catchChatError` \e -> action (CRChatError Nothing e) >> loop - where - loop = do - -- TODO this will never load full body - HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing - json <- liftEitherWith (ChatErrorRemoteCtrl . RCEInvalidResponse) $ J.eitherDecodeStrict' bodyHead -- of - action json - readTVarIO finished >>= (`unless` loop) - req = HC.requestNoBody "GET" path mempty +-- | Atomically check/register session and prepare its task list +startRemoteHostSession :: ChatMonad m => RemoteHost -> m Tasks +startRemoteHostSession RemoteHost {remoteHostId, storePath} = withNoRemoteHostSession remoteHostId $ \sessions -> do + remoteHostTasks <- newTVar [] + TM.insert remoteHostId RemoteHostSession {remoteHostTasks, storePath, remoteHostClient = Nothing} sessions + pure $ Right remoteHostTasks closeRemoteHostSession :: ChatMonad m => RemoteHostId -> m () -closeRemoteHostSession remoteHostId = do - session <- getRemoteHostSession remoteHostId - logInfo $ "Closing remote host session for " <> tshow remoteHostId - liftIO $ cancelRemoteHostSession session - chatWriteVar currentRemoteHost Nothing - chatModifyVar remoteHostSessions $ M.delete remoteHostId +closeRemoteHostSession rhId = do + logNote $ "Closing remote host session for " <> tshow rhId + chatModifyVar currentRemoteHost $ \cur -> if cur == Just rhId then Nothing else cur -- only wipe the closing RH + session <- withRemoteHostSession rhId $ \sessions rhs -> Right rhs <$ TM.delete rhId sessions + cancelRemoteHostSession session cancelRemoteHostSession :: MonadUnliftIO m => RemoteHostSession -> m () -cancelRemoteHostSession = \case - RemoteHostSessionStarting {announcer} -> cancel announcer - RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient +cancelRemoteHostSession RemoteHostSession {remoteHostTasks, remoteHostClient} = do + cancelTasks remoteHostTasks + mapM_ closeRemoteHostClient remoteHostClient createRemoteHost :: ChatMonad m => m RemoteHostInfo createRemoteHost = do - let rhName = "TODO" -- you don't have remote host name here, it will be passed from remote host - ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) rhName + ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) "Host" storePath <- liftIO randomStorePath - remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath rhName caKey caCert - rcName <- chatReadVar localDeviceName - let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} - pure RemoteHostInfo {remoteHostId, storePath, displayName = rhName, remoteCtrlOOB, sessionActive = False} + let remoteName = "" -- will be passed from remote host in hello + remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath remoteName caKey caCert + localName <- chatReadVar localDeviceName + let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = localName} + pure RemoteHostInfo {remoteHostId, storePath, displayName = remoteName, remoteCtrlOOB, sessionActive = False} -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -191,241 +178,111 @@ deleteRemoteHost rhId = do Nothing -> logWarn "Local file store not available while deleting remote host" withStore' (`deleteRemoteHostRecord` rhId) -processRemoteCommand :: ChatMonad m => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse -processRemoteCommand RemoteHostSessionStarting {} _ = pure $ chatCmdError Nothing "remote command sent before session started" -processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = - uploadFile cmd >>= relayCommand ctrlClient - where - fileCmd cmdPfx cn hostPath = utf8String $ unwords [cmdPfx, chatNameStr cn, hostPath] - uploadFile = \case - SendFile cn ctrlPath -> fileCmd "/file" cn <$> storeRemoteFile ctrlClient ctrlPath - SendImage cn ctrlPath -> fileCmd "/image" cn <$> storeRemoteFile ctrlClient ctrlPath - -- TODO APISendMessage should only be used with host path already, and UI has to upload file first. - -- The problem is that we cannot have different file names in host and controller, because it simply won't be able to show files. - -- So we need to ask the host to store files BEFORE storing them in the app storage and use host names in the command and to store the file locally if it has to be shown, - -- or don't even store it if it's not image/video. - -- The current approach won't work. - -- It also does not account for local file encryption. - -- Also, local file encryption setting should be tracked in the controller, as otherwise host won't be able to decide what to do having received the upload command. - APISendMessage {composedMessage = cm@ComposedMessage {fileSource = Just CryptoFile {filePath = ctrlPath, cryptoArgs}}} -> do - hostPath <- storeRemoteFile ctrlClient ctrlPath - let cm' = cm {fileSource = Just CryptoFile {filePath = hostPath, cryptoArgs}} :: ComposedMessage - -- TODO we shouldn't manipulate JSON like that - pure $ B.takeWhile (/= '{') s <> B.toStrict (J.encode cm') - _ -> pure s +processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ByteString -> m ChatResponse +processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} s = liftRH remoteHostId $ remoteSend rhc s +processRemoteCommand _ _ _ = pure $ chatCmdError Nothing "remote command sent before session started" -relayCommand :: ChatMonad m => HTTP2Client -> ByteString -> m ChatResponse -relayCommand http s = do - -- TODO ExceptT - let timeout' = Nothing - HTTP2Response {respBody = HTTP2Body {bodyHead}} <- - liftHTTP2 $ HTTP2.sendRequestDirect http req timeout' - -- TODO: large JSONs can overflow into buffered chunks - json <- liftEitherWith (ChatErrorRemoteCtrl . RCEInvalidResponse) $ J.eitherDecodeStrict' bodyHead - case J.fromJSON $ toTaggedJSON json of - J.Error e -> err $ show e - J.Success cr -> pure cr - where - err = pure . CRChatError Nothing . ChatErrorRemoteCtrl . RCEInvalidResponse - toTaggedJSON :: J.Value -> J.Value - toTaggedJSON = id -- owsf2tagged TODO: get from RemoteHost - req = HC.requestBuilder "POST" "/send" mempty (Binary.fromByteString s) +liftRH :: ChatMonad m => RemoteHostId -> ExceptT RemoteProtocolError IO a -> m a +liftRH rhId = liftError (ChatErrorRemoteHost rhId . RHProtocolError) --- TODO fileName is just metadata that does not determine the actual file location for UI, or whether it is encrypted or not --- fileSource is the actual file location (with information whether it is locally encrypted) -handleRcvFileComplete :: ChatMonad m => HTTP2Client -> FilePath -> User -> CIFile 'MDRcv -> m (Maybe (CIFile 'MDRcv)) -handleRcvFileComplete http storePath remoteUser f@CIFile {fileId, fileName} = - chatReadVar filesFolder >>= \case - Just baseDir -> do - let hostStore = baseDir storePath - createDirectoryIfMissing True hostStore - -- TODO the problem here is that the name may turn out to be different and nothing will work - -- file processing seems to work "accidentally", not "by design" - localPath <- uniqueCombine hostStore fileName - fetchRemoteFile http remoteUser fileId localPath - pure $ Just (f {fileName = localPath} :: CIFile 'MDRcv) - -- TODO below will not work with CLI, it should store file to download folder when not specified - -- It should not load all files when received, instead it should only load files received with /fr commands - Nothing -> Nothing <$ logError "Local file store not available while fetching remote file" +-- * Mobile side --- | Convert swift single-field sum encoding into tagged/discriminator-field -owsf2tagged :: J.Value -> J.Value -owsf2tagged = fst . convert - where - convert val = case val of - J.Object o - | JM.size o == 2 -> - case JM.toList o of - [OwsfTag, o'] -> tagged o' - [o', OwsfTag] -> tagged o' - _ -> props - | otherwise -> props - where - props = (J.Object $ fmap owsf2tagged o, False) - J.Array a -> (J.Array $ fmap owsf2tagged a, False) - _ -> (val, False) - -- `tagged` converts the pair of single-field object encoding to tagged encoding. - -- It sets innerTag returned by `convert` to True to prevent the tag being overwritten. - tagged (k, v) = (J.Object pairs, True) - where - (v', innerTag) = convert v - pairs = case v' of - -- `innerTag` indicates that internal object already has tag, - -- so the current tag cannot be inserted into it. - J.Object o - | innerTag -> pair - | otherwise -> JM.insert TaggedObjectJSONTag tag o - _ -> pair - tag = J.String $ JK.toText k - pair = JM.fromList [TaggedObjectJSONTag .= tag, TaggedObjectJSONData .= v'] - -pattern OwsfTag :: (JK.Key, J.Value) -pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) - -storeRemoteFile :: ChatMonad m => HTTP2Client -> FilePath -> m FilePath -storeRemoteFile http localFile = do - fileSize <- liftIO $ fromIntegral <$> getFileSize localFile - -- TODO configure timeout - let timeout' = Nothing - r@HTTP2Response {respBody = HTTP2Body {bodyHead}} <- - liftHTTP2 $ HTTP2.sendRequestDirect http (req fileSize) timeout' - responseStatusOK r - -- TODO what if response doesn't fit in the head? - -- it'll be solved when processing moved to POST with Command/Response types - pure $ B.unpack bodyHead - where - -- TODO local file encryption? - uri = "/store?" <> HTTP.renderSimpleQuery False [("file_name", utf8String $ takeFileName localFile)] - req size = HC.requestFile "PUT" uri mempty (HC.FileSpec localFile 0 size) - -liftHTTP2 :: ChatMonad m => IO (Either HTTP2ClientError a) -> m a -liftHTTP2 = liftEitherError $ ChatErrorRemoteCtrl . RCEHTTP2Error . show - -responseStatusOK :: ChatMonad m => HTTP2Response -> m () -responseStatusOK HTTP2Response {response} = do - let s = HC.responseStatus response - unless (s == Just Status.ok200) $ - throwError $ ChatErrorRemoteCtrl $ RCEHTTP2RespStatus $ Status.statusCode <$> s - -fetchRemoteFile :: ChatMonad m => HTTP2Client -> User -> Int64 -> FilePath -> m () -fetchRemoteFile http User {userId = remoteUserId} remoteFileId localPath = do - r@HTTP2Response {respBody} <- liftHTTP2 $ HTTP2.sendRequestDirect http req Nothing - responseStatusOK r - writeBodyToFile localPath respBody - where - req = HC.requestNoBody "GET" path mempty - path = "/fetch?" <> HTTP.renderSimpleQuery False [("user_id", bshow remoteUserId), ("file_id", bshow remoteFileId)] - --- XXX: extract to Transport.HTTP2 ? -writeBodyToFile :: MonadUnliftIO m => FilePath -> HTTP2Body -> m () -writeBodyToFile path HTTP2Body {bodyHead, bodySize, bodyPart} = do - logInfo $ "Receiving " <> tshow bodySize <> " bytes to " <> tshow path - liftIO . withFile path WriteMode $ \h -> do - hPut h bodyHead - mapM_ (hPutBodyChunks h) bodyPart - -hPutBodyChunks :: Handle -> (Int -> IO ByteString) -> IO () -hPutBodyChunks h getChunk = do - chunk <- getChunk defaultHTTP2BufferSize - unless (B.null chunk) $ do - hPut h chunk - hPutBodyChunks h getChunk - --- TODO command/response pattern, remove REST conventions -processControllerRequest :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m () -processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, sendResponse} = do - logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) - res <- tryChatError $ case (method, ps) of - ("GET", []) -> getHello - ("POST", ["send"]) -> sendCommand - ("GET", ["recv"]) -> recvMessage - ("PUT", ["store"]) -> storeFile - ("GET", ["fetch"]) -> fetchFile - unexpected -> respondWith Status.badRequest400 $ "unexpected method/path: " <> Binary.putStringUtf8 (show unexpected) - case res of - Left e -> logError $ "Error handling remote controller request: (" <> tshow (method <> " " <> path) <> "): " <> tshow e - Right () -> logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) <> " OK" - where - method = fromMaybe "" $ HS.requestMethod request - path = fromMaybe "/" $ HS.requestPath request - (ps, query) = HTTP.decodePath path - getHello = respond "OK" - sendCommand = execChatCommand (bodyHead reqBody) >>= respondJSON - recvMessage = - chatReadVar remoteCtrlSession >>= \case - Nothing -> respondWith Status.internalServerError500 "session not active" - Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON - -- TODO liftEither storeFileQuery - storeFile = case storeFileQuery of - Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) - Right fileName -> do - baseDir <- fromMaybe "." <$> chatReadVar filesFolder - localPath <- uniqueCombine baseDir fileName - logDebug $ "Storing controller file to " <> tshow (baseDir, localPath) - writeBodyToFile localPath reqBody - let storeRelative = takeFileName localPath - respond $ Binary.putStringUtf8 storeRelative - where - storeFileQuery = parseField "file_name" $ A.many1 (A.satisfy $ not . isPathSeparator) - -- TODO move to ExceptT monad, catch errors in one place, convert errors to responses - fetchFile = case fetchFileQuery of - Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err) - Right (userId, fileId) -> do - logInfo $ "Fetching file " <> tshow fileId <> " from user " <> tshow userId - x <- withStore' $ \db -> runExceptT $ do - user <- getUser db userId - getRcvFileTransfer db user fileId - -- TODO this error handling is very ad-hoc, there is no separation between Chat errors and responses - case x of - Right RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> do - baseDir <- fromMaybe "." <$> chatReadVar filesFolder - let fullPath = baseDir filePath - size <- fromInteger <$> getFileSize fullPath - liftIO . sendResponse . HS.responseFile Status.ok200 mempty $ HS.FileSpec fullPath 0 size - Right _ -> respondWith Status.internalServerError500 "The requested file is not complete" - Left SEUserNotFound {} -> respondWith Status.notFound404 "User not found" - Left SERcvFileNotFound {} -> respondWith Status.notFound404 "File not found" - _ -> respondWith Status.internalServerError500 "Store error" - where - fetchFileQuery = - (,) - <$> parseField "user_id" A.decimal - <*> parseField "file_id" A.decimal - - parseField :: ByteString -> A.Parser a -> Either String a - parseField field p = maybe (Left $ "missing " <> B.unpack field) (A.parseOnly $ p <* A.endOfInput) (join $ lookup field query) - - respondJSON :: (J.ToJSON a) => a -> m () - respondJSON = respond . Binary.fromLazyByteString . J.encode - - respond = respondWith Status.ok200 - respondWith status = liftIO . sendResponse . HS.responseBuilder status [] - --- * ChatRequest handlers - -startRemoteCtrl :: ChatMonad m => (ByteString -> m ChatResponse) -> m () +startRemoteCtrl :: forall m . ChatMonad m => (ByteString -> m ChatResponse) -> m () startRemoteCtrl execChatCommand = do - checkNoRemoteCtrlSession + logInfo "Starting remote host" + checkNoRemoteCtrlSession -- tiny race with the final @chatWriteVar@ until the setup finishes and supervisor spawned + discovered <- newTVarIO mempty + discoverer <- async $ discoverRemoteCtrls discovered -- TODO extract to a controller service singleton size <- asks $ tbqSize . config remoteOutputQ <- newTBQueueIO size - discovered <- newTVarIO mempty - discoverer <- async $ discoverRemoteCtrls discovered accepted <- newEmptyTMVarIO - supervisor <- async $ runSupervisor discovered accepted + supervisor <- async $ runHost discovered accepted $ handleRemoteCommand execChatCommand remoteOutputQ chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} + +-- | Track remote host lifecycle in controller session state and signal UI on its progress +runHost :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> TMVar RemoteCtrlId -> (HTTP2Request -> m ()) -> m () +runHost discovered accepted handleHttp = do + remoteCtrlId <- atomically (readTMVar accepted) -- wait for ??? + rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) + source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint + toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False + atomically $ writeTVar discovered mempty -- flush unused sources + server <- async $ Discovery.connectRevHTTP2 source fingerprint handleHttp -- spawn server for remote protocol commands + chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} + toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True + _ <- waitCatch server -- wait for the server to finish + chatWriteVar remoteCtrlSession Nothing + toView CRRemoteCtrlStopped + +handleRemoteCommand :: forall m . ChatMonad m => (ByteString -> m ChatResponse) -> TBQueue ChatResponse -> HTTP2Request -> m () +handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do + logDebug "handleRemoteCommand" + liftRC (tryRemoteError parseRequest) >>= \case + Right (getNext, rc) -> processCommand getNext rc `catchAny` (reply . RRProtocolError . RPEException . tshow) + Left e -> reply $ RRProtocolError e where - runSupervisor discovered accepted = do - remoteCtrlId <- atomically (readTMVar accepted) - rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) - source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure - toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False - atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand) - chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} - toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True - _ <- waitCatch server - chatWriteVar remoteCtrlSession Nothing - toView CRRemoteCtrlStopped + parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand) + parseRequest = do + (header, getNext) <- parseHTTP2Body request reqBody + (getNext,) <$> liftEitherWith (RPEInvalidJSON . T.pack) (J.eitherDecodeStrict' header) + processCommand :: GetChunk -> RemoteCommand -> m () + processCommand getNext = \case + RCHello {deviceName = desktopName} -> handleHello desktopName >>= reply + RCSend {command} -> handleSend execChatCommand command >>= reply + RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply + RCStoreFile {fileSize, encrypt} -> handleStoreFile fileSize encrypt getNext >>= reply + RCGetFile {filePath} -> handleGetFile filePath replyWith + reply :: RemoteResponse -> m () + reply = (`replyWith` \_ -> pure ()) + replyWith :: Respond m + replyWith rr attach = + liftIO . sendResponse . responseStreaming N.status200 [] $ \send flush -> do + send $ sizePrefixedEncode rr + attach send + flush + +type GetChunk = Int -> IO ByteString + +type SendChunk = Builder -> IO () + +type Respond m = RemoteResponse -> (SendChunk -> IO ()) -> m () + +liftRC :: ChatMonad m => ExceptT RemoteProtocolError IO a -> m a +liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) + +tryRemoteError :: ExceptT RemoteProtocolError IO a -> ExceptT RemoteProtocolError IO (Either RemoteProtocolError a) +tryRemoteError = tryAllErrors (RPEException . tshow) +{-# INLINE tryRemoteError #-} + +handleHello :: ChatMonad m => Text -> m RemoteResponse +handleHello desktopName = do + logInfo $ "Hello from " <> tshow desktopName + mobileName <- chatReadVar localDeviceName + pure RRHello {encoding = localEncoding, deviceName = mobileName} + +handleSend :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteResponse +handleSend execChatCommand command = do + logDebug $ "Send: " <> tshow command + -- execChatCommand checks for remote-allowed commands + -- convert errors thrown in ChatMonad into error responses to prevent aborting the protocol wrapper + RRChatResponse <$> execChatCommand (encodeUtf8 command) `catchError` (pure . CRChatError Nothing) + +handleRecv :: MonadUnliftIO m => Int -> TBQueue ChatResponse -> m RemoteResponse +handleRecv time events = do + logDebug $ "Recv: " <> tshow time + RRChatEvent <$> (timeout time . atomically $ readTBQueue events) + +handleStoreFile :: ChatMonad m => Word32 -> Maybe Bool -> GetChunk -> m RemoteResponse +handleStoreFile _fileSize _encrypt _getNext = error "TODO" <$ logError "TODO: handleStoreFile" + +handleGetFile :: ChatMonad m => FilePath -> Respond m -> m () +handleGetFile path reply = do + logDebug $ "GetFile: " <> tshow path + withFile path ReadMode $ \h -> do + fileSize' <- hFileSize h + when (fileSize' > toInteger (maxBound :: Word32)) $ throwIO RPEFileTooLarge + let fileSize = fromInteger fileSize' + reply RRFile {fileSize} $ \send -> hSendFile h send fileSize -- TODO the problem with this code was that it wasn't clear where the recursion can happen, -- by splitting receiving and processing to two functions it becomes clear diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index 01c6d12c6e..5630c540da 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -1,4 +1,5 @@ {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} @@ -20,6 +21,7 @@ module Simplex.Chat.Remote.Discovery ) where +import Control.Logger.Simple import Control.Monad import Data.ByteString (ByteString) import Data.Default (def) @@ -27,16 +29,17 @@ import Data.String (IsString) import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP +import Simplex.Chat.Remote.Types (Tasks, registerAsync) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Transport (supportedParameters) import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportClientConfig, runTransportClient) import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError, attachHTTP2Client, connTimeout, defaultHTTP2ClientConfig) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) -import Simplex.Messaging.Util (whenM) +import Simplex.Messaging.Util (ifM, tshow, whenM) import UnliftIO import UnliftIO.Concurrent @@ -53,18 +56,33 @@ pattern BROADCAST_PORT = "5226" -- | Announce tls server, wait for connection and attach http2 client to it. -- -- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: StrEncoding a => a -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) -announceRevHTTP2 invite credentials finishAction = do +announceRevHTTP2 :: StrEncoding a => Tasks -> a -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 tasks invite credentials finishAction = do httpClient <- newEmptyMVar started <- newEmptyTMVarIO finished <- newEmptyMVar - announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ runAnnouncer (strEncode invite) - tlsServer <- startTLSServer started credentials $ \tls -> cancel announcer >> runHTTP2Client finished httpClient tls - _ <- forkIO $ do - readMVar finished + _ <- forkIO $ readMVar finished >> finishAction -- attach external cleanup action to session lock + announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ do + logInfo $ "Starting announcer for " <> tshow (strEncode invite) + runAnnouncer (strEncode invite) + tasks `registerAsync` announcer + tlsServer <- startTLSServer started credentials $ \tls -> do + logInfo $ "Incoming connection for " <> tshow (strEncode invite) cancel announcer - cancel tlsServer - finishAction + runHTTP2Client finished httpClient tls `catchAny` (logError . tshow) + logInfo $ "Client finished for " <> tshow (strEncode invite) + -- BUG: this should be handled in HTTP2Client wrapper + _ <- forkIO $ do + waitCatch tlsServer >>= \case + Left err | fromException err == Just AsyncCancelled -> logDebug "tlsServer cancelled" + Left err -> do + logError $ "tlsServer failed to start: " <> tshow err + void $ tryPutMVar httpClient $ Left HCNetworkError + void . atomically $ tryPutTMVar started False + Right () -> pure () + void $ tryPutMVar finished () + tasks `registerAsync` tlsServer + logInfo $ "Waiting for client for " <> tshow (strEncode invite) readMVar httpClient -- | Broadcast invite with link-local datagrams @@ -77,8 +95,7 @@ runAnnouncer inviteBS = do UDP.send sock inviteBS threadDelay 1000000 --- TODO what prevents second client from connecting to the same server? --- Do we need to start multiple TLS servers for different mobile hosts? +-- XXX: Do we need to start multiple TLS servers for different mobile hosts? startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig where @@ -92,11 +109,17 @@ startTLSServer started credentials = async . liftIO . runTransportServer started -- | Attach HTTP2 client and hold the TLS until the attached client finishes. runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () -runHTTP2Client finishedVar clientVar tls = do - attachHTTP2Client config ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls >>= putMVar clientVar - readMVar finishedVar +runHTTP2Client finishedVar clientVar tls = + ifM (isEmptyMVar clientVar) + attachClient + (logError "HTTP2 session already started on this listener") where - config = defaultHTTP2ClientConfig { connTimeout = 86400000000 } + attachClient = do + client <- attachHTTP2Client config ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls + putMVar clientVar client + readMVar finishedVar + -- TODO connection timeout + config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} withListener :: (MonadUnliftIO m) => (UDP.ListenSocket -> m a) -> m a withListener = bracket openListener (liftIO . UDP.stop) @@ -122,5 +145,9 @@ attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TL attachHTTP2Server processRequest tls = do withRunInIO $ \unlift -> runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do - reqBody <- getHTTP2Body r defaultHTTP2BufferSize + reqBody <- getHTTP2Body r doNotPrefetchHead unlift $ processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} + +-- | Suppress storing initial chunk in bodyHead, forcing clients and servers to stream chunks +doNotPrefetchHead :: Int +doNotPrefetchHead = 0 diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs new file mode 100644 index 0000000000..65a851f718 --- /dev/null +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -0,0 +1,199 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Remote.Protocol where + +import Control.Logger.Simple +import Control.Monad +import Control.Monad.Except +import Data.Aeson ((.=)) +import qualified Data.Aeson as J +import qualified Data.Aeson.Key as JK +import qualified Data.Aeson.KeyMap as JM +import Data.Aeson.TH (deriveJSON) +import qualified Data.Aeson.Types as JT +import Data.ByteString (ByteString) +import Data.ByteString.Builder (Builder, word32BE, lazyByteString) +import qualified Data.ByteString.Lazy as BL +import Data.String (fromString) +import Data.Text (Text) +import Data.Text.Encoding (decodeUtf8) +import Data.Word (Word32) +import qualified Network.HTTP.Types as N +import qualified Network.HTTP2.Client as H +import Network.Transport.Internal (decodeWord32) +import Simplex.Chat.Controller (ChatResponse) +import Simplex.Chat.Remote.Types +import Simplex.Messaging.Crypto.File (CryptoFile) +import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) +import Simplex.Messaging.Transport.Buffer (getBuffered) +import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) +import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile, hSendFile) +import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow, whenM) +import System.FilePath (()) +import UnliftIO +import UnliftIO.Directory (doesFileExist, getFileSize) + +data RemoteCommand + = RCHello {deviceName :: Text} + | RCSend {command :: Text} -- TODO maybe ChatCommand here? + | RCRecv {wait :: Int} -- this wait should be less than HTTP timeout + | -- local file encryption is determined by the host, but can be overridden for videos + RCStoreFile {fileSize :: Word32, encrypt :: Maybe Bool} -- requires attachment + | RCGetFile {filePath :: FilePath} + deriving (Show) + +data RemoteResponse + = RRHello {encoding :: PlatformEncoding, deviceName :: Text} + | RRChatResponse {chatResponse :: ChatResponse} + | RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout + | RRFileStored {fileSource :: CryptoFile} + | RRFile {fileSize :: Word32} -- provides attachment + | RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- ^ The protocol error happened on the server side + deriving (Show) + +-- Force platform-independent encoding as the types aren't UI-visible +$(deriveJSON (taggedObjectJSON $ dropPrefix "RC") ''RemoteCommand) +$(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) + +-- * Client side / desktop + +createRemoteHostClient :: HTTP2Client -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient +createRemoteHostClient httpClient desktopName = do + logInfo "Sending initial hello" + (_getNext, rr) <- sendRemoteCommand httpClient localEncoding Nothing RCHello {deviceName = desktopName} + case rr of + rrh@RRHello {encoding, deviceName = mobileName} -> do + logInfo $ "Got initial hello: " <> tshow rrh + when (encoding == PEKotlin && localEncoding == PESwift) $ throwError RPEIncompatibleEncoding + pure RemoteHostClient {remoteEncoding = encoding, remoteDeviceName = mobileName, httpClient} + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + +closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m () +closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient + +-- ** Commands + +remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse +remoteSend RemoteHostClient {httpClient, remoteEncoding} cmd = do + (_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCSend {command = decodeUtf8 cmd} + case rr of + RRChatResponse cr -> pure cr + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + +remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) +remoteRecv RemoteHostClient {httpClient, remoteEncoding} ms = do + (_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCRecv {wait=ms} + case rr of + RRChatEvent cr_ -> pure cr_ + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + +remoteStoreFile :: RemoteHostClient -> FilePath -> Maybe Bool -> ExceptT RemoteProtocolError IO CryptoFile +remoteStoreFile RemoteHostClient {httpClient, remoteEncoding} localPath encrypt = do + (_getNext, rr) <- withFile localPath ReadMode $ \h -> do + fileSize' <- hFileSize h + when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileTooLarge + let fileSize = fromInteger fileSize' + sendRemoteCommand httpClient remoteEncoding (Just (h, fileSize)) RCStoreFile {encrypt, fileSize} + case rr of + RRFileStored {fileSource} -> pure fileSource + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + +-- TODO this should work differently for CLI and UI clients +-- CLI - potentially, create new unique names and report them as created +-- UI - always use the same names and report error if file already exists +-- alternatively, CLI should also use a fixed folder for remote session +-- Possibly, path in the database should be optional and CLI commands should allow configuring it per session or use temp or download folder +remoteGetFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath +remoteGetFile RemoteHostClient {httpClient, remoteEncoding} baseDir filePath = do + (getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCGetFile {filePath} + expectedSize <- case rr of + RRFile {fileSize} -> pure fileSize + _ -> throwError $ RPEUnexpectedResponse $ tshow rr + whenM (liftIO $ doesFileExist localFile) $ throwError RPEStoredFileExists + rc <- liftIO $ withFile localFile WriteMode $ \h -> hReceiveFile getNext h expectedSize + when (rc /= 0) $ throwError RPEInvalidSize + whenM ((== expectedSize) . fromIntegral <$> getFileSize localFile) $ throwError RPEInvalidSize + pure localFile + where + localFile = baseDir filePath + +sendRemoteCommand :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) +sendRemoteCommand http remoteEncoding attachment_ rc = do + HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect http httpRequest Nothing + (header, getNext) <- parseHTTP2Body response respBody + rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecodeStrict header >>= JT.parseEither J.parseJSON . convertJSON remoteEncoding localEncoding + pure (getNext, rr) + where + httpRequest = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do + send $ sizePrefixedEncode rc + case attachment_ of + Nothing -> pure () + Just (h, sz) -> hSendFile h send sz + flush + +-- * Transport-level wrappers + +convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value +convertJSON _remote@PEKotlin _local@PEKotlin = id +convertJSON PESwift PESwift = id +convertJSON PESwift PEKotlin = owsf2tagged +convertJSON PEKotlin PESwift = error "unsupported convertJSON: K/S" -- guarded by createRemoteHostClient + +-- | Convert swift single-field sum encoding into tagged/discriminator-field +owsf2tagged :: J.Value -> J.Value +owsf2tagged = fst . convert + where + convert val = case val of + J.Object o + | JM.size o == 2 -> + case JM.toList o of + [OwsfTag, o'] -> tagged o' + [o', OwsfTag] -> tagged o' + _ -> props + | otherwise -> props + where + props = (J.Object $ fmap owsf2tagged o, False) + J.Array a -> (J.Array $ fmap owsf2tagged a, False) + _ -> (val, False) + -- `tagged` converts the pair of single-field object encoding to tagged encoding. + -- It sets innerTag returned by `convert` to True to prevent the tag being overwritten. + tagged (k, v) = (J.Object pairs, True) + where + (v', innerTag) = convert v + pairs = case v' of + -- `innerTag` indicates that internal object already has tag, + -- so the current tag cannot be inserted into it. + J.Object o + | innerTag -> pair + | otherwise -> JM.insert TaggedObjectJSONTag tag o + _ -> pair + tag = J.String $ JK.toText k + pair = JM.fromList [TaggedObjectJSONTag .= tag, TaggedObjectJSONData .= v'] + +pattern OwsfTag :: (JK.Key, J.Value) +pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) + +-- | Convert a command or a response into 'Builder'. +sizePrefixedEncode :: J.ToJSON a => a -> Builder +sizePrefixedEncode value = word32BE (fromIntegral $ BL.length json) <> lazyByteString json + where + json = J.encode value + +-- | Parse HTTP request or response to a size-prefixed chunk and a function to read more. +parseHTTP2Body :: HTTP2BodyChunk a => a -> HTTP2Body -> ExceptT RemoteProtocolError IO (ByteString, Int -> IO ByteString) +parseHTTP2Body hr HTTP2Body {bodyBuffer} = do + rSize <- liftIO $ decodeWord32 <$> getNext 4 + when (rSize > fromIntegral (maxBound :: Int)) $ throwError RPEInvalidSize + r <- liftIO $ getNext $ fromIntegral rSize + pure (r, getNext) + where + getNext sz = getBuffered bodyBuffer sz Nothing $ getBodyChunk hr diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 67fe7c6ffa..de54813a4d 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -5,10 +6,39 @@ module Simplex.Chat.Remote.Types where +import Control.Exception import qualified Data.Aeson.TH as J import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) +import UnliftIO + +data RemoteHostClient = RemoteHostClient + { remoteEncoding :: PlatformEncoding, + remoteDeviceName :: Text, + httpClient :: HTTP2Client + } + +data RemoteHostSession = RemoteHostSession + { remoteHostTasks :: Tasks, + remoteHostClient :: Maybe RemoteHostClient, + storePath :: FilePath + } + +data RemoteProtocolError + = RPEInvalidSize -- ^ size prefix is malformed + | RPEInvalidJSON {invalidJSON :: Text} -- ^ failed to parse RemoteCommand or RemoteResponse + | RPEIncompatibleEncoding + | RPEUnexpectedFile + | RPENoFile + | RPEFileTooLarge + | RPEUnexpectedResponse {response :: Text} -- ^ Wrong response received for the command sent + | RPEStoredFileExists -- ^ A file already exists in the destination position + | RPEHTTP2 {http2Error :: Text} + | RPEException {someException :: Text} + deriving (Show, Exception) type RemoteHostId = Int64 @@ -30,8 +60,6 @@ data RemoteCtrlOOB = RemoteCtrlOOB } deriving (Show) -$(J.deriveJSON J.defaultOptions ''RemoteCtrlOOB) - data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, storePath :: FilePath, @@ -41,8 +69,6 @@ data RemoteHostInfo = RemoteHostInfo } deriving (Show) -$(J.deriveJSON J.defaultOptions ''RemoteHostInfo) - type RemoteCtrlId = Int64 data RemoteCtrl = RemoteCtrl @@ -53,8 +79,6 @@ data RemoteCtrl = RemoteCtrl } deriving (Show) -$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrl) - data RemoteCtrlInfo = RemoteCtrlInfo { remoteCtrlId :: RemoteCtrlId, displayName :: Text, @@ -64,4 +88,38 @@ data RemoteCtrlInfo = RemoteCtrlInfo } deriving (Show) +-- TODO: put into a proper place +data PlatformEncoding + = PESwift + | PEKotlin + deriving (Show, Eq) + +localEncoding :: PlatformEncoding +#if defined(darwin_HOST_OS) && defined(swiftJSON) +localEncoding = PESwift +#else +localEncoding = PEKotlin +#endif + +type Tasks = TVar [Async ()] + +asyncRegistered :: MonadUnliftIO m => Tasks -> m () -> m () +asyncRegistered tasks action = async action >>= registerAsync tasks + +registerAsync :: MonadIO m => Tasks -> Async () -> m () +registerAsync tasks = atomically . modifyTVar tasks . (:) + +cancelTasks :: (MonadIO m) => Tasks -> m () +cancelTasks tasks = readTVarIO tasks >>= mapM_ cancel + +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) + +$(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) + +$(J.deriveJSON J.defaultOptions ''RemoteCtrlOOB) + +$(J.deriveJSON J.defaultOptions ''RemoteHostInfo) + +$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrl) + $(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrlInfo) diff --git a/stack.yaml b/stack.yaml index 6e047f7e6c..49aae2639a 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 1ad69cf74f18f25713ce564e1629d2538313b9e0 + commit: deb3fc73595ceae34902d3402d075e3a531d5221 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index a250cdfcf6..188fe27597 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -9,7 +9,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) import MobileTests -import Simplex.Chat.Remote (owsf2tagged) +import Simplex.Chat.Remote.Protocol (owsf2tagged) import Simplex.Messaging.Parsers import Test.Hspec import Test.Hspec.QuickCheck (modifyMaxSuccess) diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index b739c19882..452f9ca21d 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -8,17 +7,18 @@ module RemoteTests where import ChatClient import ChatTests.Utils +import Control.Logger.Simple import Control.Monad import qualified Data.ByteString as B import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import Debug.Trace import Network.HTTP.Types (ok200) import qualified Network.HTTP2.Client as C import qualified Network.HTTP2.Server as S import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Simplex.Chat.Controller as Controller +import Simplex.Chat.Remote.Types import qualified Simplex.Chat.Remote.Discovery as Discovery import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -27,17 +27,21 @@ import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) +import Simplex.Messaging.Util import System.FilePath (makeRelative, ()) import Test.Hspec import UnliftIO +import UnliftIO.Concurrent import UnliftIO.Directory remoteTests :: SpecWith FilePath -remoteTests = fdescribe "Handshake" $ do +remoteTests = describe "Remote" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test - it "connects desktop and mobile" remoteHandshakeTest - it "send messages via remote desktop" remoteCommandTest + it "performs protocol handshake" remoteHandshakeTest + it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check + it "sends messages" remoteMessageTest + xit "sends files" remoteFileTest -- * Low-level TLS with ephemeral credentials @@ -51,14 +55,14 @@ genCredentialsTest _tmp = do Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler where serverHandler serverTls = do - traceM " - Sending from server" + logNote "Sending from server" Transport.putLn serverTls "hi client" - traceM " - Reading from server" + logNote "Reading from server" Transport.getLn serverTls `shouldReturn` "hi server" clientHandler clientTls = do - traceM " - Sending from client" + logNote "Sending from client" Transport.putLn clientTls "hi server" - traceM " - Reading from client" + logNote "Reading from client" Transport.getLn clientTls `shouldReturn` "hi client" -- * UDP discovery and rever HTTP2 @@ -66,34 +70,37 @@ genCredentialsTest _tmp = do announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () announceDiscoverHttp2Test _tmp = do (fingerprint, credentials) <- genTestCredentials + tasks <- newTVarIO [] finished <- newEmptyMVar controller <- async $ do - traceM " - Controller: starting" + logNote "Controller: starting" bracket - (Discovery.announceRevHTTP2 fingerprint credentials (putMVar finished ()) >>= either (fail . show) pure) + (Discovery.announceRevHTTP2 tasks fingerprint credentials (putMVar finished ()) >>= either (fail . show) pure) closeHTTP2Client ( \http -> do - traceM " - Controller: got client" + logNote "Controller: got client" sendRequest http (C.requestNoBody "GET" "/" []) (Just 10000000) >>= \case Left err -> do - traceM " - Controller: got error" + logNote "Controller: got error" fail $ show err Right HTTP2Response {} -> - traceM " - Controller: got response" + logNote "Controller: got response" ) host <- async $ Discovery.withListener $ \sock -> do (N.SockAddrInet _port addr, invite) <- Discovery.recvAnnounce sock strDecode invite `shouldBe` Right fingerprint - traceM " - Host: connecting" + logNote "Host: connecting" server <- async $ Discovery.connectTLSClient (THIPv4 $ N.hostAddressToTuple addr) fingerprint $ \tls -> do - traceM " - Host: got tls" + logNote "Host: got tls" flip Discovery.attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do - traceM " - Host: got request" + logNote "Host: got request" sendResponse $ S.responseNoBody ok200 [] - traceM " - Host: sent response" + logNote "Host: sent response" takeMVar finished `finally` cancel server - traceM " - Host: finished" - (waitBoth host controller `shouldReturn` ((), ())) `onException` (cancel host >> cancel controller) + logNote "Host: finished" + tasks `registerAsync` controller + tasks `registerAsync` host + (waitBoth host controller `shouldReturn` ((), ())) `finally` cancelTasks tasks -- * Chat commands @@ -101,62 +108,59 @@ remoteHandshakeTest :: (HasCallStack) => FilePath -> IO () remoteHandshakeTest = testChat2 aliceProfile bobProfile $ \desktop mobile -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" - desktop ##> "/create remote host" - desktop <## "remote host 1 created" - desktop <## "connection code:" - fingerprint <- getTermLine desktop + + startRemote mobile desktop + + logNote "Session active" desktop ##> "/list remote hosts" desktop <## "Remote hosts:" - desktop <## "1. TODO" -- TODO host name probably should be Maybe, as when host is created there is no name yet - desktop ##> "/start remote host 1" - desktop <## "ok" - - mobile ##> "/start remote ctrl" - mobile <## "ok" - mobile <## "remote controller announced" - mobile <## "connection code:" - fingerprint' <- getTermLine mobile - fingerprint' `shouldBe` fingerprint - mobile ##> "/list remote ctrls" - mobile <## "No remote controllers" - mobile ##> ("/register remote ctrl " <> fingerprint' <> " " <> "My desktop") - mobile <## "remote controller 1 registered" - mobile ##> "/list remote ctrls" - mobile <## "Remote controllers:" - mobile <## "1. My desktop" - mobile ##> "/accept remote ctrl 1" - mobile <## "ok" -- alternative scenario: accepted before controller start - mobile <## "remote controller 1 connecting to My desktop" - mobile <## "remote controller 1 connected, My desktop" - - traceM " - Session active" - desktop ##> "/list remote hosts" - desktop <## "Remote hosts:" - desktop <## "1. TODO (active)" + desktop <## "1. (active)" mobile ##> "/list remote ctrls" mobile <## "Remote controllers:" mobile <## "1. My desktop (active)" - traceM " - Shutting desktop" - desktop ##> "/stop remote host 1" - desktop <## "ok" + stopMobile mobile desktop `catchAny` (logError . tshow) + -- TODO: add a case for 'stopDesktop' + desktop ##> "/delete remote host 1" desktop <## "ok" desktop ##> "/list remote hosts" desktop <## "No remote hosts" - traceM " - Shutting mobile" - mobile ##> "/stop remote ctrl" - mobile <## "ok" - mobile <## "remote controller stopped" mobile ##> "/delete remote ctrl 1" mobile <## "ok" mobile ##> "/list remote ctrls" mobile <## "No remote controllers" -remoteCommandTest :: (HasCallStack) => FilePath -> IO () -remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do +remoteMessageTest :: (HasCallStack) => FilePath -> IO () +remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do + startRemote mobile desktop + contactBob desktop bob + + logNote "sending messages" + desktop #> "@bob hello there 🙂" + bob <# "alice> hello there 🙂" + bob #> "@alice hi" + desktop <# "bob> hi" + + logNote "post-remote checks" + stopMobile mobile desktop + + mobile ##> "/contacts" + mobile <## "bob (Bob)" + + bob ##> "/contacts" + bob <## "alice (Alice)" + + desktop ##> "/contacts" + -- empty contact list on desktop-local + + threadDelay 1000000 + logNote "done" + +remoteFileTest :: (HasCallStack) => FilePath -> IO () +remoteFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) mobile <## "ok" @@ -167,6 +171,89 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob bob ##> ("/_files_folder " <> bobFiles) bob <## "ok" + startRemote mobile desktop + contactBob desktop bob + + rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) + desktopStore <- case M.lookup 1 rhs of + Just RemoteHostSession {storePath} -> pure storePath + _ -> fail "Host session 1 should be started" + + doesFileExist "./tests/tmp/mobile_files/test.pdf" `shouldReturn` False + doesFileExist (desktopFiles desktopStore "test.pdf") `shouldReturn` False + mobileName <- userName mobile + + bobsFile <- makeRelative bobFiles <$> makeAbsolute "tests/fixtures/test.pdf" + bob #> ("/f @" <> mobileName <> " " <> bobsFile) + bob <## "use /fc 1 to cancel sending" + + desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" + desktop <## "use /fr 1 [
/ | ] to receive it" + desktop ##> "/fr 1" + concurrentlyN_ + [ do + bob <## "started sending file 1 (test.pdf) to alice" + bob <## "completed sending file 1 (test.pdf) to alice", + do + desktop <## "saving file 1 from bob to test.pdf" + desktop <## "started receiving file 1 (test.pdf) from bob" + ] + let desktopReceived = desktopFiles desktopStore "test.pdf" + -- desktop <## ("completed receiving file 1 (" <> desktopReceived <> ") from bob") + desktop <## "completed receiving file 1 (test.pdf) from bob" + bobsFileSize <- getFileSize bobsFile + -- getFileSize desktopReceived `shouldReturn` bobsFileSize + bobsFileBytes <- B.readFile bobsFile + -- B.readFile desktopReceived `shouldReturn` bobsFileBytes + + -- test file transit on mobile + mobile ##> "/fs 1" + mobile <## "receiving file 1 (test.pdf) complete, path: test.pdf" + getFileSize (mobileFiles "test.pdf") `shouldReturn` bobsFileSize + B.readFile (mobileFiles "test.pdf") `shouldReturn` bobsFileBytes + + logNote "file received" + + desktopFile <- makeRelative desktopFiles <$> makeAbsolute "tests/fixtures/logo.jpg" -- XXX: not necessary for _send, but required for /f + logNote $ "sending " <> tshow desktopFile + doesFileExist (bobFiles "logo.jpg") `shouldReturn` False + doesFileExist (mobileFiles "logo.jpg") `shouldReturn` False + desktop ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/logo.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" + desktop <# "@bob hi, sending a file" + desktop <# "/f @bob logo.jpg" + desktop <## "use /fc 2 to cancel sending" + + bob <# "alice> hi, sending a file" + bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" + bob <## "use /fr 2 [/ | ] to receive it" + bob ##> "/fr 2" + concurrentlyN_ + [ do + bob <## "saving file 2 from alice to logo.jpg" + bob <## "started receiving file 2 (logo.jpg) from alice" + bob <## "completed receiving file 2 (logo.jpg) from alice" + bob ##> "/fs 2" + bob <## "receiving file 2 (logo.jpg) complete, path: logo.jpg", + do + desktop <## "started sending file 2 (logo.jpg) to bob" + desktop <## "completed sending file 2 (logo.jpg) to bob" + ] + desktopFileSize <- getFileSize desktopFile + getFileSize (bobFiles "logo.jpg") `shouldReturn` desktopFileSize + getFileSize (mobileFiles "logo.jpg") `shouldReturn` desktopFileSize + + desktopFileBytes <- B.readFile desktopFile + B.readFile (bobFiles "logo.jpg") `shouldReturn` desktopFileBytes + B.readFile (mobileFiles "logo.jpg") `shouldReturn` desktopFileBytes + + logNote "file sent" + + stopMobile mobile desktop + +-- * Utils + +startRemote :: TestCC -> TestCC -> IO () +startRemote mobile desktop = do desktop ##> "/create remote host" desktop <## "remote host 1 created" desktop <## "connection code:" @@ -189,7 +276,9 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob mobile <## "remote controller 1 connected, My desktop" desktop <## "remote host 1 connected" - traceM " - exchanging contacts" +contactBob :: TestCC -> TestCC -> IO () +contactBob desktop bob = do + logNote "exchanging contacts" bob ##> "/c" inv' <- getInvitation bob desktop ##> ("/c " <> inv') @@ -198,102 +287,33 @@ remoteCommandTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob (desktop <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - traceM " - sending messages" - desktop #> "@bob hello there 🙂" - bob <# "alice> hello there 🙂" - bob #> "@alice hi" - desktop <# "bob> hi" - - withXFTPServer $ do - rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) - desktopStore <- case M.lookup 1 rhs of - Just Controller.RemoteHostSessionStarted {storePath} -> pure storePath - _ -> fail "Host session 1 should be started" - - doesFileExist "./tests/tmp/mobile_files/test.pdf" `shouldReturn` False - doesFileExist (desktopFiles desktopStore "test.pdf") `shouldReturn` False - mobileName <- userName mobile - - bobsFile <- makeRelative bobFiles <$> makeAbsolute "tests/fixtures/test.pdf" - bob #> ("/f @" <> mobileName <> " " <> bobsFile) - bob <## "use /fc 1 to cancel sending" - - desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" - desktop <## "use /fr 1 [/ | ] to receive it" - desktop ##> "/fr 1" - concurrently_ - do - bob <## "started sending file 1 (test.pdf) to alice" - bob <## "completed sending file 1 (test.pdf) to alice" - - do - desktop <## "saving file 1 from bob to test.pdf" - desktop <## "started receiving file 1 (test.pdf) from bob" - - let desktopReceived = desktopFiles desktopStore "test.pdf" - desktop <## ("completed receiving file 1 (" <> desktopReceived <> ") from bob") - bobsFileSize <- getFileSize bobsFile - getFileSize desktopReceived `shouldReturn` bobsFileSize - bobsFileBytes <- B.readFile bobsFile - B.readFile desktopReceived `shouldReturn` bobsFileBytes - - -- test file transit on mobile - mobile ##> "/fs 1" - mobile <## "receiving file 1 (test.pdf) complete, path: test.pdf" - getFileSize (mobileFiles "test.pdf") `shouldReturn` bobsFileSize - B.readFile (mobileFiles "test.pdf") `shouldReturn` bobsFileBytes - - traceM " - file received" - - desktopFile <- makeRelative desktopFiles <$> makeAbsolute "tests/fixtures/logo.jpg" -- XXX: not necessary for _send, but required for /f - traceM $ " - sending " <> show desktopFile - doesFileExist (bobFiles "logo.jpg") `shouldReturn` False - doesFileExist (mobileFiles "logo.jpg") `shouldReturn` False - desktop ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/logo.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" - desktop <# "@bob hi, sending a file" - desktop <# "/f @bob logo.jpg" - desktop <## "use /fc 2 to cancel sending" - - bob <# "alice> hi, sending a file" - bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" - bob <## "use /fr 2 [/ | ] to receive it" - bob ##> "/fr 2" - concurrently_ - do - bob <## "saving file 2 from alice to logo.jpg" - bob <## "started receiving file 2 (logo.jpg) from alice" - bob <## "completed receiving file 2 (logo.jpg) from alice" - bob ##> "/fs 2" - bob <## "receiving file 2 (logo.jpg) complete, path: logo.jpg" - do - desktop <## "started sending file 2 (logo.jpg) to bob" - desktop <## "completed sending file 2 (logo.jpg) to bob" - desktopFileSize <- getFileSize desktopFile - getFileSize (bobFiles "logo.jpg") `shouldReturn` desktopFileSize - getFileSize (mobileFiles "logo.jpg") `shouldReturn` desktopFileSize - - desktopFileBytes <- B.readFile desktopFile - B.readFile (bobFiles "logo.jpg") `shouldReturn` desktopFileBytes - B.readFile (mobileFiles "logo.jpg") `shouldReturn` desktopFileBytes - - traceM " - file sent" - - traceM " - post-remote checks" - mobile ##> "/stop remote ctrl" - mobile <## "ok" - concurrently_ - (mobile <## "remote controller stopped") - (desktop <## "remote host 1 stopped") - - mobile ##> "/contacts" - mobile <## "bob (Bob)" - - traceM " - done" - --- * Utils - genTestCredentials :: IO (C.KeyHash, TLS.Credentials) genTestCredentials = do caCreds <- liftIO $ genCredentials Nothing (0, 24) "CA" sessionCreds <- liftIO $ genCredentials (Just caCreds) (0, 24) "Session" pure . tlsCredentials $ sessionCreds :| [caCreds] + +stopDesktop :: HasCallStack => TestCC -> TestCC -> IO () +stopDesktop mobile desktop = do + logWarn "stopping via desktop" + desktop ##> "/stop remote host 1" + -- desktop <## "ok" + concurrently_ + (desktop <## "remote host 1 stopped") + (eventually 3 $ mobile <## "remote controller stopped") + +stopMobile :: HasCallStack => TestCC -> TestCC -> IO () +stopMobile mobile desktop = do + logWarn "stopping via mobile" + mobile ##> "/stop remote ctrl" + mobile <## "ok" + concurrently_ + (mobile <## "remote controller stopped") + (eventually 3 $ desktop <## "remote host 1 stopped") + +-- | Run action with extended timeout +eventually :: Int -> IO a -> IO a +eventually retries action = tryAny action >>= \case -- TODO: only catch timeouts + Left err | retries == 0 -> throwIO err + Left _ -> eventually (retries - 1) action + Right r -> pure r diff --git a/tests/Test.hs b/tests/Test.hs index 071ff3791e..568f9688d0 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -19,7 +19,7 @@ import WebRTCTests main :: IO () main = do - setLogLevel LogError -- LogDebug + setLogLevel LogError withGlobalLogging logCfg . hspec $ do describe "Schema dump" schemaDumpTest describe "SimpleX chat markdown" markdownTests diff --git a/tests/ViewTests.hs b/tests/ViewTests.hs index 7c7a2f0e03..085a56af44 100644 --- a/tests/ViewTests.hs +++ b/tests/ViewTests.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE BlockArguments #-} {-# LANGUAGE OverloadedStrings #-} module ViewTests where From 5c57987e9f321c98a079a495f8104b0b57d4a718 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 13:58:51 +0100 Subject: [PATCH 097/294] add smp11, 12 and 14 to preset servers --- src/Simplex/Chat.hs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 33dd262b01..3581b80c29 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -150,7 +150,10 @@ defaultChatConfig = _defaultSMPServers :: NonEmpty SMPServerWithAuth _defaultSMPServers = L.fromList - [ "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", + [ "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", + "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", + "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", + "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", From 8891314507a5f5fc05c540ca83b80391dc4e7a87 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 14:19:24 +0100 Subject: [PATCH 098/294] core: update simplexmq (fixes ordering issue during message delivery) --- cabal.project | 4 ++-- scripts/nix/sha256map.nix | 4 ++-- stack.yaml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cabal.project b/cabal.project index 9a9a3e25da..8a05c0bb95 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 1ad69cf74f18f25713ce564e1629d2538313b9e0 + tag: cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc source-repository-package type: git @@ -19,7 +19,7 @@ source-repository-package source-repository-package type: git location: https://github.com/kazu-yamamoto/http2.git - tag: b5a1b7200cf5bc7044af34ba325284271f6dff25 + tag: 804fa283f067bd3fd89b8c5f8d25b3047813a517 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 17d650cb09..60b9505fea 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,7 @@ { - "https://github.com/simplex-chat/simplexmq.git"."1ad69cf74f18f25713ce564e1629d2538313b9e0" = "1kil0962pn3ksnxh7dcwcbnkidz95yl31rm4m585ps7wnh6fp0l9"; + "https://github.com/simplex-chat/simplexmq.git"."cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc" = "0xcbvxz2nszm1sdh6gvmfzjf9n2ldsarmmzbl6j6b5hg9i1mppc6"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; - "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; + "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; diff --git a/stack.yaml b/stack.yaml index 6e047f7e6c..99b9d179cd 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,9 +49,9 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 1ad69cf74f18f25713ce564e1629d2538313b9e0 + commit: cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc - github: kazu-yamamoto/http2 - commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 + commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: f814ee68b16a9447fbb467ccc8f29bdd3546bfd9 From b25c2e3a091e7bf7258c9485f790787a346835a9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 14:56:51 +0100 Subject: [PATCH 099/294] ui: add 10 minutes SimpleX Lock delay (#3255) --- apps/ios/Shared/Views/UserSettings/PrivacySettings.swift | 4 +++- .../chat/simplex/common/views/usersettings/PrivacySettings.kt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 71ff7b88bf..51bfb96940 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -356,7 +356,7 @@ struct SimplexLockView: View { var id: Self { self } } - let laDelays: [Int] = [10, 30, 60, 180, 0] + let laDelays: [Int] = [10, 30, 60, 180, 600, 0] func laDelayText(_ t: Int) -> LocalizedStringKey { let m = t / 60 @@ -378,6 +378,7 @@ struct SimplexLockView: View { Text(mode.text) } } + .frame(height: 36) if performLA { Picker("Lock after", selection: $laLockDelay) { let delays = laDelays.contains(laLockDelay) ? laDelays : [laLockDelay] + laDelays @@ -385,6 +386,7 @@ struct SimplexLockView: View { Text(laDelayText(t)) } } + .frame(height: 36) if showChangePassword && laMode == .passcode { Button("Change passcode") { changeLAPassword() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 211fe59671..84ab87c653 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -317,7 +317,7 @@ private fun showUserGroupsReceiptsAlert( ) } -private val laDelays = listOf(10, 30, 60, 180, 0) +private val laDelays = listOf(10, 30, 60, 180, 600, 0) @Composable fun SimplexLockView( From 79275424ea4b650e408262491c67495fd7431690 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 15:06:55 +0100 Subject: [PATCH 100/294] core: 5.4.0.2 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 6fed41b2ae..6f333d9bb1 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.1 +version: 5.4.0.2 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 824b558135..cdb659a66e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.1 +version: 5.4.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 3e0b6826bf13255cab1e5b90833e08e92f884b04 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 17:50:08 +0100 Subject: [PATCH 101/294] ios: 5.3.2 build 179 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 1cbe61dea0..c75fa63c52 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1486,7 +1486,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1528,7 +1528,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1608,7 +1608,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1640,7 +1640,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1672,7 +1672,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1718,7 +1718,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; From 1401f562882b4a3d341ac41a8330c6adb031a86a Mon Sep 17 00:00:00 2001 From: Era Dorta Date: Sun, 22 Oct 2023 19:15:15 +0200 Subject: [PATCH 102/294] cli: update docker image to use ghc 9.6.2 (#3234) --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index b78e2f2448..0c0788c81d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,12 +8,12 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/ chmod +x /usr/bin/ghcup # Install ghc -RUN ghcup install ghc 8.10.7 +RUN ghcup install ghc 9.6.2 # Install cabal -RUN ghcup install cabal +RUN ghcup install cabal 3.10.1.0 # Set both as default -RUN ghcup set ghc 8.10.7 && \ - ghcup set cabal +RUN ghcup set ghc 9.6.2 && \ + ghcup set cabal 3.10.1.0 COPY . /project WORKDIR /project From 530ec701711c1767e71457ce5c0de7ae35ff0f24 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 23 Oct 2023 01:47:27 +0800 Subject: [PATCH 103/294] android, desktop: support calls on desktop and moved www dir to different root (#3219) * android, desktop: support calls on desktop and moved www dir to different root * add page title, fix links on Android, change timeouts * using worker in desktop Chrome and Safari * ui changes * end call button in app bar * fix android * a lot of enhancements * fix after merge master * layout * sound play on call --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/multiplatform/android/build.gradle.kts | 1 + apps/multiplatform/common/build.gradle.kts | 2 + .../common/views/call/CallView.android.kt | 86 ++++--- .../views/chatlist/ChatListView.android.kt | 8 + .../chat/simplex/common/model/ChatModel.kt | 2 +- .../chat/simplex/common/model/SimpleXAPI.kt | 10 +- .../simplex/common/views/call/CallManager.kt | 12 +- .../chat/simplex/common/views/call/WebRTC.kt | 43 ++-- .../simplex/common/views/chat/ChatView.kt | 75 ++++-- .../views/chat/group/GroupMemberInfoView.kt | 5 +- .../common/views/chat/item/CICallItemView.kt | 4 +- .../views/chatlist/ChatListNavLinkView.kt | 13 +- .../common/views/chatlist/ChatListView.kt | 8 + .../common/views/helpers/AlertManager.kt | 3 +- .../common/views/helpers/ChatInfoImage.kt | 29 +++ .../resources}/assets/www/README.md | 0 .../resources/assets/www/android}/call.html | 4 +- .../resources/assets/www/android}/style.css | 0 .../resources}/assets/www/call.html | 0 .../commonMain/resources}/assets/www/call.js | 58 ++++- .../resources/assets/www/desktop/call.html | 50 ++++ .../www/desktop/images/ic_call_end_filled.svg | 1 + .../assets/www/desktop/images/ic_mic.svg | 1 + .../assets/www/desktop/images/ic_mic_off.svg | 1 + .../www/desktop/images/ic_phone_in_talk.svg | 1 + .../www/desktop/images/ic_videocam_filled.svg | 1 + .../www/desktop/images/ic_videocam_off.svg | 1 + .../www/desktop/images/ic_volume_down.svg | 1 + .../www/desktop/images/ic_volume_up.svg | 1 + .../resources/assets/www/desktop/style.css | 127 ++++++++++ .../resources/assets/www/desktop/ui.js | 80 ++++++ .../resources}/assets/www/lz-string.min.js | 0 .../resources/assets/www}/style.css | 0 .../common/platform/RecAndPlay.desktop.kt | 32 ++- .../common/views/call/CallView.desktop.kt | 239 +++++++++++++++++- .../chatlist/ChatListNavLinkView.desktop.kt | 2 +- .../views/chatlist/ChatListView.desktop.kt | 75 ++++++ .../desktopMain/resources/media/ring_once.mp3 | Bin 0 -> 72269 bytes packages/simplex-chat-webrtc/copy | 24 +- packages/simplex-chat-webrtc/package.json | 2 +- .../simplex-chat-webrtc/src/android/call.html | 26 ++ .../simplex-chat-webrtc/src/android/style.css | 41 +++ packages/simplex-chat-webrtc/src/call.ts | 80 ++++-- .../simplex-chat-webrtc/src/desktop/call.html | 50 ++++ .../src/desktop/images/ic_call_end_filled.svg | 1 + .../src/desktop/images/ic_mic.svg | 1 + .../src/desktop/images/ic_mic_off.svg | 1 + .../src/desktop/images/ic_phone_in_talk.svg | 1 + .../src/desktop/images/ic_videocam_filled.svg | 1 + .../src/desktop/images/ic_videocam_off.svg | 1 + .../src/desktop/images/ic_volume_down.svg | 1 + .../src/desktop/images/ic_volume_up.svg | 1 + .../simplex-chat-webrtc/src/desktop/style.css | 127 ++++++++++ .../simplex-chat-webrtc/src/desktop/ui.ts | 87 +++++++ 54 files changed, 1262 insertions(+), 159 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt rename apps/multiplatform/{android/src/main => common/src/commonMain/resources}/assets/www/README.md (100%) rename {packages/simplex-chat-webrtc/src => apps/multiplatform/common/src/commonMain/resources/assets/www/android}/call.html (88%) rename apps/multiplatform/{android/src/main/assets/www => common/src/commonMain/resources/assets/www/android}/style.css (100%) rename apps/multiplatform/{android/src/main => common/src/commonMain/resources}/assets/www/call.html (100%) rename apps/multiplatform/{android/src/main => common/src/commonMain/resources}/assets/www/call.js (92%) create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js rename apps/multiplatform/{android/src/main => common/src/commonMain/resources}/assets/www/lz-string.min.js (100%) rename {packages/simplex-chat-webrtc/src => apps/multiplatform/common/src/commonMain/resources/assets/www}/style.css (100%) create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt create mode 100644 apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 create mode 100644 packages/simplex-chat-webrtc/src/android/call.html create mode 100644 packages/simplex-chat-webrtc/src/android/style.css create mode 100644 packages/simplex-chat-webrtc/src/desktop/call.html create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/style.css create mode 100644 packages/simplex-chat-webrtc/src/desktop/ui.ts diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 67a8fea87b..873f33b22f 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -77,6 +77,7 @@ android { } jniLibs.useLegacyPackaging = rootProject.extra["compression.level"] as Int != 0 } + android.sourceSets["main"].assets.setSrcDirs(listOf("../common/src/commonMain/resources/assets")) val isRelease = gradle.startParameter.taskNames.find { it.toLowerCase().contains("release") } != null val isBundle = gradle.startParameter.taskNames.find { it.toLowerCase().contains("bundle") } != null // if (isRelease) { diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 9fb40c93d8..13ca2c309d 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -98,6 +98,8 @@ kotlin { implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("org.slf4j:slf4j-simple:2.0.7") implementation("uk.co.caprica:vlcj:4.7.3") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a") } } val desktopTest by getting diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 260182b5a4..790345e97e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -43,6 +44,9 @@ import chat.simplex.res.MR import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -52,7 +56,7 @@ actual fun ActiveCallView() { val chatModel = ChatModel BackHandler(onBack = { val call = chatModel.activeCall.value - if (call != null) withApi { chatModel.callManager.endCall(call) } + if (call != null) withBGApi { chatModel.callManager.endCall(call) } }) val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } @@ -112,30 +116,30 @@ actual fun ActiveCallView() { if (call != null) { Log.d(TAG, "has active call $call") when (val r = apiMsg.resp) { - is WCallResponse.Capabilities -> withApi { + is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) chatModel.controller.apiSendCallInvitation(call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } - is WCallResponse.Offer -> withApi { + is WCallResponse.Offer -> withBGApi { chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } - is WCallResponse.Answer -> withApi { + is WCallResponse.Answer -> withBGApi { chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } - is WCallResponse.Ice -> withApi { + is WCallResponse.Ice -> withBGApi { chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - chatModel.activeCall.value = call.copy(callState = CallState.Connected) + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) setCallSound(call.soundSpeaker, audioViaBluetooth) } - withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } } catch (e: Error) { Log.d(TAG,"call status ${r.state.connectionState} not used") } @@ -145,9 +149,12 @@ actual fun ActiveCallView() { setCallSound(call.soundSpeaker, audioViaBluetooth) } } + is WCallResponse.End -> { + withBGApi { chatModel.callManager.endCall(call) } + } is WCallResponse.Ended -> { chatModel.activeCall.value = call.copy(callState = CallState.Ended) - withApi { chatModel.callManager.endCall(call) } + withBGApi { chatModel.callManager.endCall(call) } chatModel.showCallView.value = false } is WCallResponse.Ok -> when (val cmd = apiMsg.command) { @@ -162,7 +169,7 @@ actual fun ActiveCallView() { is WCallCommand.Camera -> { chatModel.activeCall.value = call.copy(localCamera = cmd.camera) if (!call.audioEnabled) { - chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false) + chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) } } is WCallCommand.End -> @@ -187,11 +194,14 @@ actual fun ActiveCallView() { // Lock orientation to portrait in order to have good experience with calls activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT chatModel.activeCallViewIsVisible.value = true + // After the first call, End command gets added to the list which prevents making another calls + chatModel.callCommand.removeAll { it is WCallCommand.End } onDispose { activity.volumeControlStream = prevVolumeControlStream // Unlock orientation activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED chatModel.activeCallViewIsVisible.value = false + chatModel.callCommand.clear() } } } @@ -201,9 +211,9 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot ActiveCallOverlayLayout( call = call, speakerCanBeEnabled = !audioViaBluetooth.value, - dismiss = { withApi { chatModel.callManager.endCall(call) } }, - toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) }, - toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) }, + dismiss = { withBGApi { chatModel.callManager.endCall(call) } }, + toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) }, + toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) }, toggleSound = { var call = chatModel.activeCall.value if (call != null) { @@ -212,7 +222,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot setCallSound(call.soundSpeaker, audioViaBluetooth) } }, - flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) } + flipCamera = { chatModel.callCommand.add(WCallCommand.Camera(call.localCamera.flipped)) } ) } @@ -439,7 +449,7 @@ private fun DisabledBackgroundCallsButton() { //} @Composable -fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) { +fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { val scope = rememberCoroutineScope() val webView = remember { mutableStateOf(null) } val permissionsState = rememberMultiplePermissionsState( @@ -470,13 +480,19 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa webView.value = null } } - LaunchedEffect(callCommand.value, webView.value) { - val cmd = callCommand.value - val wv = webView.value - if (cmd != null && wv != null) { - Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") - processCommand(wv, cmd) - callCommand.value = null + val wv = webView.value + if (wv != null) { + LaunchedEffect(Unit) { + snapshotFlow { callCommand.firstOrNull() } + .distinctUntilChanged() + .filterNotNull() + .collect { + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirst() + Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") + processCommand(wv, cmd) + } + } } } val assetLoader = WebViewAssetLoader.Builder() @@ -502,7 +518,7 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa } } } - this.webViewClient = LocalContentWebViewClient(assetLoader) + this.webViewClient = LocalContentWebViewClient(webView, assetLoader) this.clearHistory() this.clearCache(true) this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface") @@ -512,19 +528,10 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa webViewSettings.javaScriptEnabled = true webViewSettings.mediaPlaybackRequiresUserGesture = false webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE - this.loadUrl("file:android_asset/www/call.html") + this.loadUrl("file:android_asset/www/android/call.html") } } - ) { wv -> - Log.d(TAG, "WebRTCView: webview ready") - // for debugging - // wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) - scope.launch { - delay(2000L) - wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) - webView.value = wv - } - } + ) { /* WebView */ } } } } @@ -539,19 +546,28 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) { // for debugging // onResponse(message) onResponse(json.decodeFromString(message)) - } catch (e: Error) { + } catch (e: Exception) { Log.e(TAG, "failed parsing WebView message: $message") } } } -private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { +private class LocalContentWebViewClient(val webView: MutableState, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { return assetLoader.shouldInterceptRequest(request.url) } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) + webView.value = view + Log.d(TAG, "WebRTCView: webview ready") + // for debugging + // view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) + } } @Preview diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt new file mode 100644 index 0000000000..cb74664a48 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -0,0 +1,8 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.runtime.* +import chat.simplex.common.views.helpers.* +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ad033387ab..767c678c1a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -88,7 +88,7 @@ object ChatModel { val activeCallInvitation = mutableStateOf(null) val activeCall = mutableStateOf(null) val activeCallViewIsVisible = mutableStateOf(false) - val callCommand = mutableStateOf(null) + val callCommand = mutableStateListOf() val showCallView = mutableStateOf(false) val switchingCall = mutableStateOf(false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index da09ea132f..58850ce056 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1647,25 +1647,25 @@ object ChatController { val useRelay = appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() Log.d(TAG, ".callOffer iceServers $iceServers") - chatModel.callCommand.value = WCallCommand.Offer( + chatModel.callCommand.add(WCallCommand.Offer( offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey, iceServers = iceServers, relay = useRelay - ) + )) } } is CR.CallAnswer -> { withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived) - chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates) + chatModel.callCommand.add(WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)) } } is CR.CallExtraInfo -> { withCall(r, r.contact) { _ -> - chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates) + chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)) } } is CR.CallEnded -> { @@ -1674,7 +1674,7 @@ object ChatController { chatModel.callManager.reportCallRemoteEnded(invitation = invitation) } withCall(r, r.contact) { _ -> - chatModel.callCommand.value = WCallCommand.End + chatModel.callCommand.add(WCallCommand.End) withApi { chatModel.activeCall.value = null chatModel.showCallView.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index fe4da718a8..f601776f98 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -3,8 +3,6 @@ package chat.simplex.common.views.call import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.withApi -import chat.simplex.common.views.helpers.withBGApi -import chat.simplex.common.views.usersettings.showInDevelopingAlert import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.minutes @@ -26,10 +24,6 @@ class CallManager(val chatModel: ChatModel) { } fun acceptIncomingCall(invitation: RcvCallInvitation) { - if (appPlatform.isDesktop) { - return showInDevelopingAlert() - } - val call = chatModel.activeCall.value if (call == null) { justAcceptIncomingCall(invitation = invitation) @@ -58,12 +52,12 @@ class CallManager(val chatModel: ChatModel) { val useRelay = controller.appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() Log.d(TAG, "answerIncomingCall iceServers: $iceServers") - callCommand.value = WCallCommand.Start( + callCommand.add(WCallCommand.Start( media = invitation.callType.media, aesKey = invitation.sharedKey, iceServers = iceServers, relay = useRelay - ) + )) callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { activeCallInvitation.value = null @@ -80,7 +74,7 @@ class CallManager(val chatModel: ChatModel) { showCallView.value = false } else { Log.d(TAG, "CallManager.endCall: ending call...") - callCommand.value = WCallCommand.End + callCommand.add(WCallCommand.End) showCallView.value = false controller.apiEndCall(call.contact) activeCall.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 3e14414fc7..4be49d4c07 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -1,7 +1,5 @@ package chat.simplex.common.views.call -import androidx.compose.runtime.Composable -import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* import chat.simplex.res.MR @@ -23,16 +21,17 @@ data class Call( val videoEnabled: Boolean = localMedia == CallMediaType.Video, val soundSpeaker: Boolean = localMedia == CallMediaType.Video, var localCamera: VideoCamera = VideoCamera.User, - val connectionInfo: ConnectionInfo? = null + val connectionInfo: ConnectionInfo? = null, + var connectedAt: Instant? = null ) { val encrypted: Boolean get() = localEncrypted && sharedKey != null val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false - val encryptionStatus: String @Composable get() = when(callState) { + val encryptionStatus: String get() = when(callState) { CallState.WaitCapabilities -> "" - CallState.InvitationSent -> stringResource(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) - CallState.InvitationAccepted -> stringResource(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) - else -> stringResource(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) + CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) + CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) + else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) } val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected @@ -49,16 +48,16 @@ enum class CallState { Connected, Ended; - val text: String @Composable get() = when(this) { - WaitCapabilities -> stringResource(MR.strings.callstate_starting) - InvitationSent -> stringResource(MR.strings.callstate_waiting_for_answer) - InvitationAccepted -> stringResource(MR.strings.callstate_starting) - OfferSent -> stringResource(MR.strings.callstate_waiting_for_confirmation) - OfferReceived -> stringResource(MR.strings.callstate_received_answer) - AnswerReceived -> stringResource(MR.strings.callstate_received_confirmation) - Negotiated -> stringResource(MR.strings.callstate_connecting) - Connected -> stringResource(MR.strings.callstate_connected) - Ended -> stringResource(MR.strings.callstate_ended) + val text: String get() = when(this) { + WaitCapabilities -> generalGetString(MR.strings.callstate_starting) + InvitationSent -> generalGetString(MR.strings.callstate_waiting_for_answer) + InvitationAccepted -> generalGetString(MR.strings.callstate_starting) + OfferSent -> generalGetString(MR.strings.callstate_waiting_for_confirmation) + OfferReceived -> generalGetString(MR.strings.callstate_received_answer) + AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation) + Negotiated -> generalGetString(MR.strings.callstate_connecting) + Connected -> generalGetString(MR.strings.callstate_connected) + Ended -> generalGetString(MR.strings.callstate_ended) } } @@ -67,13 +66,14 @@ enum class CallState { @Serializable sealed class WCallCommand { - @Serializable @SerialName("capabilities") object Capabilities: WCallCommand() + @Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand() @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand() @Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() + @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @Serializable @SerialName("end") object End: WCallCommand() } @@ -85,6 +85,7 @@ sealed class WCallResponse { @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse() @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() + @Serializable @SerialName("end") object End: WCallResponse() @Serializable @SerialName("ended") object Ended: WCallResponse() @Serializable @SerialName("ok") object Ok: WCallResponse() @Serializable @SerialName("error") data class Error(val message: String): WCallResponse() @@ -106,14 +107,14 @@ sealed class WCallResponse { } @Serializable data class CallCapabilities(val encryption: Boolean) @Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) { - val text: String @Composable get() { + val text: String get() { val local = localCandidate?.candidateType val remote = remoteCandidate?.candidateType return when { local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host -> - stringResource(MR.strings.call_connection_peer_to_peer) + generalGetString(MR.strings.call_connection_peer_to_peer) local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay -> - stringResource(MR.strings.call_connection_via_relay) + generalGetString(MR.strings.call_connection_via_relay) else -> "${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}" } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 4afcdacbd8..40f2b32e58 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -33,7 +33,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.platform.AudioPlayer -import chat.simplex.common.views.usersettings.showInDevelopingAlert import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -274,23 +273,24 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: withApi { chatModel.controller.apiJoinGroup(groupId) } }, startCall = out@ { media -> - if (appPlatform.isDesktop) { - return@out showInDevelopingAlert() - } withBGApi { val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) chatModel.showCallView.value = true - chatModel.callCommand.value = WCallCommand.Capabilities + chatModel.callCommand.add(WCallCommand.Capabilities(media)) } } }, + endCall = { + val call = chatModel.activeCall.value + if (call != null) withApi { chatModel.callManager.endCall(call) } + }, acceptCall = { contact -> hideKeyboard(view) val invitation = chatModel.callInvitations.remove(contact.id) if (invitation == null) { - AlertManager.shared.showAlertMsg("Call already ended!") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended)) } else { chatModel.callManager.acceptIncomingCall(invitation = invitation) } @@ -433,6 +433,7 @@ fun ChatLayout( cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, + endCall: () -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -491,7 +492,7 @@ fun ChatLayout( } Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, changeNtfsState, onSearchValueChanged) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, @@ -520,6 +521,7 @@ fun ChatInfoToolbar( back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, + endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, @@ -540,6 +542,7 @@ fun ChatInfoToolbar( } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val menuItems = arrayListOf<@Composable () -> Unit>() + val activeCall by remember { chatModel.activeCall } menuItems.add { ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { showMenu.value = false @@ -548,20 +551,52 @@ fun ChatInfoToolbar( } if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) { - barButtons.add { - IconButton({ - showMenu.value = false - startCall(CallMediaType.Audio) - }, - enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { - Icon( - painterResource(MR.images.ic_call_500), - stringResource(MR.strings.icon_descr_more_button), - tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) + if (activeCall == null) { + barButtons.add { + IconButton( + { + showMenu.value = false + startCall(CallMediaType.Audio) + }, + enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active + ) { + Icon( + painterResource(MR.images.ic_call_500), + stringResource(MR.strings.icon_descr_more_button), + tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } + } else if (activeCall?.contact?.id == chat.id) { + barButtons.add { + val call = remember { chatModel.activeCall }.value + val connectedAt = call?.connectedAt + if (connectedAt != null) { + val time = remember { mutableStateOf(durationText(0)) } + LaunchedEffect(Unit) { + while (true) { + time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt()) + delay(250) + } + } + val sp50 = with(LocalDensity.current) { 50.sp.toDp() } + Text(time.value, Modifier.widthIn(min = sp50)) + } + } + barButtons.add { + IconButton({ + showMenu.value = false + endCall() + }) { + Icon( + painterResource(MR.images.ic_call_end_filled), + null, + tint = MaterialTheme.colors.error + ) + } } } - if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { + if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active && activeCall == null) { menuItems.add { ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { showMenu.value = false @@ -1290,6 +1325,7 @@ fun PreviewChatLayout() { cancelFile = {}, joinGroup = {}, startCall = {}, + endCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, @@ -1359,6 +1395,7 @@ fun PreviewGroupChatLayout() { cancelFile = {}, joinGroup = {}, startCall = {}, + endCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e281c57622..e486aca4a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -3,7 +3,6 @@ package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer import SectionDividerSpaced -import SectionItemView import SectionSpacer import SectionTextFooter import SectionView @@ -35,7 +34,7 @@ import chat.simplex.common.views.newchat.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.common.views.chatlist.openChat +import chat.simplex.common.views.chatlist.openLoadedChat import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -87,7 +86,7 @@ fun GroupMemberInfoView( if (memberContact != null) { val memberChat = Chat(ChatInfo.Direct(memberContact), chatItems = arrayListOf()) chatModel.addChat(memberChat) - openChat(memberChat, chatModel) + openLoadedChat(memberChat, chatModel) closeAll() chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index 3e4dce7f4a..6f165515ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -68,7 +68,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) { // sharedKey: invitation.sharedKey // ) // m.showCallView = true -// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) +// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true) // } else { // AlertManager.shared.showAlertMsg(title: "Call already ended!") // } @@ -141,7 +141,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) { // sharedKey: invitation.sharedKey // ) // m.showCallView = true -// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) +// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true) // } else { // AlertManager.shared.showAlertMsg(title: "Call already ended!") // } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index b16bf4c9ba..2ff33ead57 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -4,7 +4,6 @@ import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity @@ -13,10 +12,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.ui.graphics.drawscope.ContentDrawScope -import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -126,14 +121,14 @@ fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) { suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { val chat = chatModel.controller.apiGetChat(ChatType.Direct, contactId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) } } suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) { val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) } } @@ -141,12 +136,12 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") } } -suspend fun openChat(chat: Chat, chatModel: ChatModel) { +fun openLoadedChat(chat: Chat, chatModel: ChatModel) { chatModel.chatItems.clear() chatModel.chatItemStatuses.clear() chatModel.chatItems.addAll(chat.chatItems) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 66ef1cf9f9..4df62e12e3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString @@ -29,6 +30,9 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.common.views.usersettings.simplexTeamUri import chat.simplex.common.platform.* +import chat.simplex.common.views.call.Call +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* @@ -121,6 +125,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } } if (searchInList.isEmpty()) { + DesktopActiveCallOverlayLayout(newChatSheetState) NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { @@ -311,6 +316,9 @@ private fun ProgressIndicator() { ) } +@Composable +expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) + fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index d8466e9d96..35d5b8b3ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -50,11 +50,12 @@ class AlertManager { fun showAlertDialogButtonsColumn( title: String, text: AnnotatedString? = null, + onDismissRequest: (() -> Unit)? = null, buttons: @Composable () -> Unit, ) { showAlert { AlertDialog( - onDismissRequest = ::hideAlert, + onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = { Text( title, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 3a799eddf8..abc894942f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -81,6 +81,35 @@ fun ProfileImage( } } +/** [AccountCircleFilled] has its inner padding which leads to visible border if there is background underneath. + * This is workaround + * */ +@Composable +fun ProfileImageForActiveCall( + size: Dp, + image: String? = null, + color: Color = MaterialTheme.colors.secondaryVariant, +) { + if (image == null) { + Box(Modifier.requiredSize(size).clip(CircleShape)) { + Icon( + AccountCircleFilled, + contentDescription = stringResource(MR.strings.icon_descr_profile_image_placeholder), + tint = color, + modifier = Modifier.requiredSize(size + 14.dp) + ) + } + } else { + val imageBitmap = base64ToBitmap(image) + Image( + imageBitmap, + stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).clip(CircleShape) + ) + } +} + @Preview @Composable diff --git a/apps/multiplatform/android/src/main/assets/www/README.md b/apps/multiplatform/common/src/commonMain/resources/assets/www/README.md similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/README.md rename to apps/multiplatform/common/src/commonMain/resources/assets/www/README.md diff --git a/packages/simplex-chat-webrtc/src/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html similarity index 88% rename from packages/simplex-chat-webrtc/src/call.html rename to apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html index d7b3f6cefc..46910bfaf1 100644 --- a/packages/simplex-chat-webrtc/src/call.html +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html @@ -3,7 +3,7 @@ - +
- +
diff --git a/apps/multiplatform/android/src/main/assets/www/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/style.css rename to apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css diff --git a/apps/multiplatform/android/src/main/assets/www/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.html similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/call.html rename to apps/multiplatform/common/src/commonMain/resources/assets/www/call.html diff --git a/apps/multiplatform/android/src/main/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js similarity index 92% rename from apps/multiplatform/android/src/main/assets/www/call.js rename to apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index c7cf4a9324..fd574d0225 100644 --- a/apps/multiplatform/android/src/main/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -23,6 +23,9 @@ var TransformOperation; })(TransformOperation || (TransformOperation = {})); let activeCall; let answerTimeout = 30000; +var useWorker = false; +var localizedState = ""; +var localizedDescription = ""; const processCommand = (function () { const defaultIceServers = [ { urls: ["stun:stun.simplex.im:443"] }, @@ -38,9 +41,9 @@ const processCommand = (function () { iceTransportPolicy: relay ? "relay" : "all", }, iceCandidates: { - delay: 3000, - extrasInterval: 2000, - extrasTimeout: 8000, + delay: 750, + extrasInterval: 1500, + extrasTimeout: 12000, }, }; } @@ -81,6 +84,10 @@ const processCommand = (function () { if (delay) clearTimeout(delay); resolved = true; + console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)); + //const ipv6Elem = candidates.find((item) => item.candidate.includes("raddr ::")) + //candidates = ipv6Elem != undefined ? candidates.filter((elem) => elem == ipv6Elem) : candidates + //console.log("LALAL resolveIceCandidates2", JSON.stringify(candidates)) const iceCandidates = serialize(candidates); candidates = []; resolve(iceCandidates); @@ -88,19 +95,20 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return; + console.log("LALAL sendIceCandidates", JSON.stringify(candidates)); const iceCandidates = serialize(candidates); candidates = []; sendMessageToNative({ resp: { type: "ice", iceCandidates } }); } }); } - async function initializeCall(config, mediaType, aesKey, useWorker) { + async function initializeCall(config, mediaType, aesKey) { const pc = new RTCPeerConnection(config.peerConnectionConfig); const remoteStream = new MediaStream(); const localCamera = VideoCamera.User; const localStream = await getLocalMediaStream(mediaType, localCamera); const iceCandidates = getIceCandidates(pc, config); - const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker }; + const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey }; await setupMediaStreams(call); let connectionTimeout = setTimeout(connectionHandler, answerTimeout); pc.addEventListener("connectionstatechange", connectionStateChange); @@ -178,17 +186,17 @@ const processCommand = (function () { // This request for local media stream is made to prompt for camera/mic permissions on call start if (command.media) await getLocalMediaStream(command.media, VideoCamera.User); - const encryption = supportsInsertableStreams(command.useWorker); + const encryption = supportsInsertableStreams(useWorker); resp = { type: "capabilities", capabilities: { encryption } }; break; case "start": { console.log("starting incoming call - create webrtc session"); if (activeCall) endCall(); - const { media, useWorker, iceServers, relay } = command; + const { media, iceServers, relay } = command; const encryption = supportsInsertableStreams(useWorker); const aesKey = encryption ? command.aesKey : undefined; - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker); + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); @@ -202,7 +210,6 @@ const processCommand = (function () { // iceServers, // relay, // aesKey, - // useWorker, // } resp = { type: "offer", @@ -210,21 +217,23 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: { encryption }, }; + console.log("LALALs", JSON.stringify(resp)); break; } case "offer": if (activeCall) { resp = { type: "error", message: "accept: call already started" }; } - else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + else if (!supportsInsertableStreams(useWorker) && command.aesKey) { resp = { type: "error", message: "accept: encryption is not supported" }; } else { const offer = parse(command.offer); const remoteIceCandidates = parse(command.iceCandidates); - const { media, aesKey, useWorker, iceServers, relay } = command; - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker); + const { media, aesKey, iceServers, relay } = command; + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; + console.log("LALALo", JSON.stringify(remoteIceCandidates)); await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); @@ -236,6 +245,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, }; } + console.log("LALALo", JSON.stringify(resp)); break; case "answer": if (!pc) { @@ -250,6 +260,7 @@ const processCommand = (function () { else { const answer = parse(command.answer); const remoteIceCandidates = parse(command.iceCandidates); + console.log("LALALa", JSON.stringify(remoteIceCandidates)); await pc.setRemoteDescription(new RTCSessionDescription(answer)); addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; @@ -286,6 +297,11 @@ const processCommand = (function () { resp = { type: "ok" }; } break; + case "description": + localizedState = command.state; + localizedDescription = command.description; + resp = { type: "ok" }; + break; case "end": endCall(); resp = { type: "ok" }; @@ -310,12 +326,14 @@ const processCommand = (function () { catch (e) { console.log(e); } + shutdownCameraAndMic(); activeCall = undefined; resetVideoElements(); } function addIceCandidates(conn, iceCandidates) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)); + console.log("LALAL addIceCandidates", JSON.stringify(c)); } } async function setupMediaStreams(call) { @@ -335,7 +353,7 @@ const processCommand = (function () { if (call.aesKey) { if (!call.key) call.key = await callCrypto.decodeAesKey(call.aesKey); - if (call.useWorker && !call.worker) { + if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`; call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }))); call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message })); @@ -479,6 +497,11 @@ const processCommand = (function () { return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) || (!!useWorker && "RTCRtpScriptTransform" in window)); } + function shutdownCameraAndMic() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localStream) { + activeCall.localStream.getTracks().forEach((track) => track.stop()); + } + } function resetVideoElements() { const videos = getVideoElements(); if (!videos) @@ -507,6 +530,15 @@ const processCommand = (function () { } return processCommand; })(); +function toggleMedia(s, media) { + let res = false; + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks(); + for (const t of tracks) { + t.enabled = !t.enabled; + res = t.enabled; + } + return res; +} // Cryptography function - it is loaded both in the main window and in worker context (if the worker is used) function callCryptoFunction() { const initialPlainTextRequired = { diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html new file mode 100644 index 0000000000..5e945ffe64 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html @@ -0,0 +1,50 @@ + + + + SimpleX Chat WebRTC call + + + + + + + +
+
+

+

+
+
+ +
+

+ + + + +

+ +
+ + +
+ diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg new file mode 100644 index 0000000000..34c409818a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg new file mode 100644 index 0000000000..afebf258d3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg new file mode 100644 index 0000000000..941dc182a0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg new file mode 100644 index 0000000000..43cfd7cb9f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg new file mode 100644 index 0000000000..def80d4719 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg new file mode 100644 index 0000000000..07557e277e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg new file mode 100644 index 0000000000..19999e82af --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg new file mode 100644 index 0000000000..2857a913f9 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css new file mode 100644 index 0000000000..24c31fa6f7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css @@ -0,0 +1,127 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 20%; + max-width: 20%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} + +#manage-call { + position: absolute; + width: fit-content; + top: 90%; + left: 50%; + transform: translate(-50%, 0); + display: grid; + grid-auto-flow: column; + grid-column-gap: 30px; +} + +#manage-call button { + border: none; + cursor: pointer; + appearance: none; + background-color: inherit; +} + +#progress { + position: absolute; + left: 50%; + top: 50%; + margin-left: -52px; + margin-top: -52px; + border-radius: 50%; + border-top: 5px solid white; + border-right: 5px solid white; + border-bottom: 5px solid white; + border-left: 5px solid black; + width: 100px; + height: 100px; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#info-block { + position: absolute; + color: white; + line-height: 10px; + opacity: 0.8; + width: 200px; + font-family: Arial, Helvetica, sans-serif; +} + +#info-block.audio { + text-align: center; + left: 50%; + top: 50%; + margin-left: -100px; + margin-top: 100px; +} + +#info-block.video { + left: 16px; + top: 2px; +} + +#audio-call-icon { + position: absolute; + display: none; + left: 50%; + top: 50%; + margin-left: -50px; + margin-top: -44px; + width: 100px; + height: 100px; +} diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js new file mode 100644 index 0000000000..73c33ae911 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -0,0 +1,80 @@ +"use strict"; +// Override defaults to enable worker on Chrome and Safari +useWorker = window.safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1; +// Create WebSocket connection. +const socket = new WebSocket(`ws://${location.host}`); +socket.addEventListener("open", (_event) => { + console.log("Opened socket"); + sendMessageToNative = (msg) => { + console.log("Message to server: ", msg); + socket.send(JSON.stringify(msg)); + }; +}); +socket.addEventListener("message", (event) => { + const parsed = JSON.parse(event.data); + reactOnMessageFromServer(parsed); + processCommand(parsed); + console.log("Message from server: ", event.data); +}); +socket.addEventListener("close", (_event) => { + console.log("Closed socket"); + sendMessageToNative = (_msg) => { + console.log("Tried to send message to native but the socket was closed already"); + }; + window.close(); +}); +function endCallManually() { + sendMessageToNative({ resp: { type: "end" } }); +} +function toggleAudioManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) { + document.getElementById("toggle-audio").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio) + ? '' + : ''; + } +} +function toggleSpeakerManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.remoteStream) { + document.getElementById("toggle-speaker").innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio) + ? '' + : ''; + } +} +function toggleVideoManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) { + document.getElementById("toggle-video").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Video) + ? '' + : ''; + } +} +function reactOnMessageFromServer(msg) { + var _a; + switch ((_a = msg.command) === null || _a === void 0 ? void 0 : _a.type) { + case "capabilities": + document.getElementById("info-block").className = msg.command.media; + break; + case "offer": + case "start": + document.getElementById("toggle-audio").style.display = "inline-block"; + document.getElementById("toggle-speaker").style.display = "inline-block"; + if (msg.command.media == "video") { + document.getElementById("toggle-video").style.display = "inline-block"; + } + document.getElementById("info-block").className = msg.command.media; + break; + case "description": + updateCallInfoView(msg.command.state, msg.command.description); + if ((activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection.connectionState) == "connected") { + document.getElementById("progress").style.display = "none"; + if (document.getElementById("info-block").className == CallMediaType.Audio) { + document.getElementById("audio-call-icon").style.display = "block"; + } + } + break; + } +} +function updateCallInfoView(state, description) { + document.getElementById("state").innerText = state; + document.getElementById("description").innerText = description; +} +//# sourceMappingURL=ui.js.map \ No newline at end of file diff --git a/apps/multiplatform/android/src/main/assets/www/lz-string.min.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/lz-string.min.js rename to apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js diff --git a/packages/simplex-chat-webrtc/src/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/style.css similarity index 100% rename from packages/simplex-chat-webrtc/src/style.css rename to apps/multiplatform/common/src/commonMain/resources/assets/www/style.css diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 4439680c66..25fc9ec8d5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -1,16 +1,15 @@ package chat.simplex.common.platform -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.* import chat.simplex.common.model.* -import chat.simplex.common.views.helpers.AlertManager -import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.State import uk.co.caprica.vlcj.player.component.AudioPlayerComponent import java.io.File +import java.util.* import kotlin.math.max actual class RecorderNative: RecorderInterface { @@ -38,7 +37,7 @@ actual object AudioPlayer: AudioPlayerInterface { // Returns real duration of the track private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { - val absoluteFilePath = getAppFilePath(fileSource.filePath) + val absoluteFilePath = if (fileSource.isAbsolutePath) fileSource.filePath else getAppFilePath(fileSource.filePath) if (!File(absoluteFilePath).exists()) { Log.e(TAG, "No such file: ${fileSource.filePath}") return null @@ -208,6 +207,25 @@ val MediaPlayer.duration: Int get() = media().info().duration().toInt() actual object SoundPlayer: SoundPlayerInterface { - override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ } - override fun stop() { /*LALAL*/ } + var playing = false + + override fun start(scope: CoroutineScope, sound: Boolean) { + withBGApi { + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + SoundPlayer::class.java.getResource("/media/ring_once.mp3").openStream()!!.use { it.copyTo(tmpFile.outputStream()) } + playing = true + while (playing) { + if (sound) { + AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true) + } + delay(3500) + } + } + } + + override fun stop() { + playing = false + AudioPlayer.stop() + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index df45a8acb0..42def0c75f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -1,8 +1,243 @@ package chat.simplex.common.views.call -import androidx.compose.runtime.Composable +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.nanohttpd.protocols.http.IHTTPSession +import org.nanohttpd.protocols.http.response.Response +import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse +import org.nanohttpd.protocols.http.response.Status +import org.nanohttpd.protocols.websockets.* +import java.io.IOException +import java.net.URI + +private const val SERVER_HOST = "localhost" +private const val SERVER_PORT = 50395 +val connections = ArrayList() @Composable actual fun ActiveCallView() { - // LALAL + val endCall = { + val call = chatModel.activeCall.value + if (call != null) withBGApi { chatModel.callManager.endCall(call) } + } + BackHandler(onBack = endCall) + WebRTCController(chatModel.callCommand) { apiMsg -> + Log.d(TAG, "received from WebRTCController: $apiMsg") + val call = chatModel.activeCall.value + if (call != null) { + Log.d(TAG, "has active call $call") + when (val r = apiMsg.resp) { + is WCallResponse.Capabilities -> withBGApi { + val callType = CallType(call.localMedia, r.capabilities) + chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) + } + is WCallResponse.Offer -> withBGApi { + chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) + } + is WCallResponse.Answer -> withBGApi { + chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + } + is WCallResponse.Ice -> withBGApi { + chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + } + is WCallResponse.Connection -> + try { + val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") + if (callStatus == WebRTCCallStatus.Connected) { + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) + } + withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + } catch (e: Error) { + Log.d(TAG, "call status ${r.state.connectionState} not used") + } + is WCallResponse.Connected -> { + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) + } + is WCallResponse.End -> { + withBGApi { chatModel.callManager.endCall(call) } + } + is WCallResponse.Ended -> { + chatModel.activeCall.value = call.copy(callState = CallState.Ended) + withBGApi { chatModel.callManager.endCall(call) } + chatModel.showCallView.value = false + } + is WCallResponse.Ok -> when (val cmd = apiMsg.command) { + is WCallCommand.Answer -> + chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + is WCallCommand.Media -> { + when (cmd.media) { + CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable) + CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable) + } + } + is WCallCommand.Camera -> { + chatModel.activeCall.value = call.copy(localCamera = cmd.camera) + if (!call.audioEnabled) { + chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) + } + } + is WCallCommand.End -> + chatModel.showCallView.value = false + else -> {} + } + is WCallResponse.Error -> { + Log.e(TAG, "ActiveCallView: command error ${r.message}") + } + } + } + } + + SendStateUpdates() + DisposableEffect(Unit) { + chatModel.activeCallViewIsVisible.value = true + // After the first call, End command gets added to the list which prevents making another calls + chatModel.callCommand.removeAll { it is WCallCommand.End } + onDispose { + chatModel.activeCallViewIsVisible.value = false + chatModel.callCommand.clear() + } + } +} + +@Composable +private fun SendStateUpdates() { + LaunchedEffect(Unit) { + snapshotFlow { chatModel.activeCall.value } + .distinctUntilChanged() + .filterNotNull() + .collect { call -> + val state = call.callState.text + val connInfo = call.connectionInfo + // val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" + val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" + val description = call.encryptionStatus + connInfoText + chatModel.callCommand.add(WCallCommand.Description(state, description)) + } + } +} + +@Composable +fun WebRTCController(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { + val uriHandler = LocalUriHandler.current + val server = remember { + uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/") + startServer(onResponse) + } + fun processCommand(cmd: WCallCommand) { + val apiCall = WVAPICall(command = cmd) + for (connection in connections.toList()) { + try { + connection.send(json.encodeToString(apiCall)) + break + } catch (e: Exception) { + Log.e(TAG, "Failed to send message to browser: ${e.stackTraceToString()}") + } + } + } + DisposableEffect(Unit) { + onDispose { + processCommand(WCallCommand.End) + server.stop() + connections.clear() + } + } + LaunchedEffect(Unit) { + snapshotFlow { callCommand.firstOrNull() } + .distinctUntilChanged() + .filterNotNull() + .collect { + while (connections.isEmpty()) { + delay(100) + } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirst() + Log.d(TAG, "WebRTCController LaunchedEffect executing $cmd") + processCommand(cmd) + } + } + } +} + +fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { + val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { + override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) + + @Suppress("NewApi") + fun resourcesToResponse(path: String): Response { + val uri = Class.forName("chat.simplex.common.AppKt").getResource("/assets/www$path") ?: return resourceNotFound + val response = newFixedLengthResponse( + Status.OK, getMimeTypeForFile(uri.file), + uri.openStream().readAllBytes() + ) + response.setKeepAlive(true) + response.setUseGzip(true) + return response + } + + val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found") + + override fun handle(session: IHTTPSession): Response { + return when { + session.headers["upgrade"] == "websocket" -> super.handle(session) + session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html") + else -> resourcesToResponse(URI.create(session.uri).path) + } + } + } + server.start(60_000_000) + return server +} + +class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) { + override fun onOpen() { + connections.add(this) + } + + override fun onClose(closeCode: CloseCode?, reason: String?, initiatedByRemote: Boolean) { + onResponse(WVAPIMessage(null, WCallResponse.End)) + } + + override fun onMessage(message: WebSocketFrame) { + Log.d(TAG, "MyWebSocket.onMessage") + try { + // for debugging + // onResponse(message.textPayload) + onResponse(json.decodeFromString(message.textPayload)) + } catch (e: Exception) { + Log.e(TAG, "failed parsing browser message: $message") + } + } + + override fun onPong(pong: WebSocketFrame?) = Unit + + override fun onException(exception: IOException) { + Log.e(TAG, "WebSocket exception: ${exception.stackTraceToString()}") + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 0ad69d1c04..2b646f0e46 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.unit.dp import chat.simplex.common.views.helpers.* -private object NoIndication : Indication { +object NoIndication : Indication { private object NoIndicationInstance : IndicationInstance { override fun ContentDrawScope.drawIndication() { drawContent() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt new file mode 100644 index 0000000000..d2fa97f860 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -0,0 +1,75 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) { + val call = remember { chatModel.activeCall}.value + // if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) { + if (call != null && !newChatSheetState.collectAsState().value.isVisible()) { + val showMenu = remember { mutableStateOf(false) } + val media = call.peerMedia ?: call.localMedia + CompositionLocalProvider( + LocalIndication provides NoIndication + ) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.BottomEnd + ) { + Box( + Modifier + .padding(end = 71.dp, bottom = 92.dp) + .size(67.dp) + .combinedClickable(onClick = { + val chat = chatModel.getChat(call.contact.id) + if (chat != null) { + withApi { + openChat(chat.chatInfo, chatModel) + } + } + }, + onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true }, + contentAlignment = Alignment.Center + ) { + Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) { + ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image) + } + Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) { + if (media == CallMediaType.Video) { + Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White) + } else { + Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White) + } + } + DefaultDropdownMenu(showMenu) { + ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = { + withBGApi { chatModel.callManager.endCall(call) } + showMenu.value = false + }) + } + } + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 b/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..958327733694183c605a93a07096194633869ed8 GIT binary patch literal 72269 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 literal 0 HcmV?d00001 diff --git a/packages/simplex-chat-webrtc/copy b/packages/simplex-chat-webrtc/copy index 4991cdef45..770547b2cd 100755 --- a/packages/simplex-chat-webrtc/copy +++ b/packages/simplex-chat-webrtc/copy @@ -1,14 +1,24 @@ #!/bin/sh # it can be tested in the browser from dist folder -cp ./src/call.html ./dist/call.html -cp ./src/style.css ./dist/style.css +mkdir -p dist/{android,desktop,desktop/images} 2>/dev/null +cp ./src/android/call.html ./dist/android/call.html +cp ./src/android/style.css ./dist/android/style.css +cp ./src/desktop/call.html ./dist/desktop/call.html +cp ./src/desktop/style.css ./dist/desktop/style.css +cp ./src/desktop/images/* ./dist/desktop/images/ cp ./node_modules/lz-string/libs/lz-string.min.js ./dist/lz-string.min.js cp ./src/webcall.html ./dist/webcall.html cp ./src/ui.js ./dist/ui.js -# copy to android app -cp ./src/call.html ../../apps/multiplatform/android/src/main/assets/www/call.html -cp ./src/style.css ../../apps/multiplatform/android/src/main/assets/www/style.css -cp ./dist/call.js ../../apps/multiplatform/android/src/main/assets/www/call.js -cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/multiplatform/android/src/main/assets/www/lz-string.min.js +# copy to android and desktop apps +mkdir -p ../../apps/multiplatform/common/src/commonMain/resources/assets/www/{android,desktop,desktop/images} 2>/dev/null +cp ./src/android/call.html ../../apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html +cp ./src/android/style.css ../../apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css +cp ./src/desktop/call.html ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html +cp ./src/desktop/style.css ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css +cp ./src/desktop/images/* ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ + +cp ./dist/desktop/ui.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +cp ./dist/call.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index d1fc60b5a4..f11ea36343 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -40,4 +40,4 @@ "dependencies": { "lz-string": "^1.4.4" } -} \ No newline at end of file +} diff --git a/packages/simplex-chat-webrtc/src/android/call.html b/packages/simplex-chat-webrtc/src/android/call.html new file mode 100644 index 0000000000..46910bfaf1 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/android/call.html @@ -0,0 +1,26 @@ + + + + + + + + + + + +
+ +
+ diff --git a/packages/simplex-chat-webrtc/src/android/style.css b/packages/simplex-chat-webrtc/src/android/style.css new file mode 100644 index 0000000000..3d2941c71e --- /dev/null +++ b/packages/simplex-chat-webrtc/src/android/style.css @@ -0,0 +1,41 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 7b0b51ea6d..a6f036eb30 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -15,6 +15,7 @@ type WCallCommand = | WCallIceCandidates | WCEnableMedia | WCToggleCamera + | WCDescription | WCEndCall type WCallResponse = @@ -24,14 +25,15 @@ type WCallResponse = | WCallIceCandidates | WRConnection | WRCallConnected + | WRCallEnd | WRCallEnded | WROk | WRError | WCAcceptOffer -type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "end" +type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "end" -type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "ended" | "ok" | "error" +type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error" enum CallMediaType { Audio = "audio", @@ -53,15 +55,13 @@ interface IWCallResponse { interface WCCapabilities extends IWCallCommand { type: "capabilities" - media?: CallMediaType - useWorker?: boolean + media: CallMediaType } interface WCStartCall extends IWCallCommand { type: "start" media: CallMediaType aesKey?: string - useWorker?: boolean iceServers?: RTCIceServer[] relay?: boolean } @@ -76,7 +76,6 @@ interface WCAcceptOffer extends IWCallCommand { iceCandidates: string // JSON strings for RTCIceCandidateInit media: CallMediaType aesKey?: string - useWorker?: boolean iceServers?: RTCIceServer[] relay?: boolean } @@ -110,6 +109,12 @@ interface WCToggleCamera extends IWCallCommand { camera: VideoCamera } +interface WCDescription extends IWCallCommand { + type: "description" + state: string + description: string +} + interface WRCapabilities extends IWCallResponse { type: "capabilities" capabilities: CallCapabilities @@ -134,6 +139,10 @@ interface WRCallConnected extends IWCallResponse { connectionInfo: ConnectionInfo } +interface WRCallEnd extends IWCallResponse { + type: "end" +} + interface WRCallEnded extends IWCallResponse { type: "ended" } @@ -185,13 +194,15 @@ interface Call { localStream: MediaStream remoteStream: MediaStream aesKey?: string - useWorker?: boolean worker?: Worker key?: CryptoKey } let activeCall: Call | undefined let answerTimeout = 30_000 +var useWorker = false +var localizedState = "" +var localizedDescription = "" const processCommand = (function () { type RTCRtpSenderWithEncryption = RTCRtpSender & { @@ -232,9 +243,9 @@ const processCommand = (function () { iceTransportPolicy: relay ? "relay" : "all", }, iceCandidates: { - delay: 3000, - extrasInterval: 2000, - extrasTimeout: 8000, + delay: 750, + extrasInterval: 1500, + extrasTimeout: 12000, }, } } @@ -274,6 +285,7 @@ const processCommand = (function () { function resolveIceCandidates() { if (delay) clearTimeout(delay) resolved = true + console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)) const iceCandidates = serialize(candidates) candidates = [] resolve(iceCandidates) @@ -281,6 +293,7 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return + console.log("LALAL sendIceCandidates", JSON.stringify(candidates)) const iceCandidates = serialize(candidates) candidates = [] sendMessageToNative({resp: {type: "ice", iceCandidates}}) @@ -288,13 +301,13 @@ const processCommand = (function () { }) } - async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string, useWorker?: boolean): Promise { + async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { const pc = new RTCPeerConnection(config.peerConnectionConfig) const remoteStream = new MediaStream() const localCamera = VideoCamera.User const localStream = await getLocalMediaStream(mediaType, localCamera) const iceCandidates = getIceCandidates(pc, config) - const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker} + const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey} await setupMediaStreams(call) let connectionTimeout: number | undefined = setTimeout(connectionHandler, answerTimeout) pc.addEventListener("connectionstatechange", connectionStateChange) @@ -374,16 +387,16 @@ const processCommand = (function () { if (activeCall) endCall() // This request for local media stream is made to prompt for camera/mic permissions on call start if (command.media) await getLocalMediaStream(command.media, VideoCamera.User) - const encryption = supportsInsertableStreams(command.useWorker) + const encryption = supportsInsertableStreams(useWorker) resp = {type: "capabilities", capabilities: {encryption}} break case "start": { console.log("starting incoming call - create webrtc session") if (activeCall) endCall() - const {media, useWorker, iceServers, relay} = command + const {media, iceServers, relay} = command const encryption = supportsInsertableStreams(useWorker) const aesKey = encryption ? command.aesKey : undefined - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker) + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection const offer = await pc.createOffer() await pc.setLocalDescription(offer) @@ -397,7 +410,6 @@ const processCommand = (function () { // iceServers, // relay, // aesKey, - // useWorker, // } resp = { type: "offer", @@ -405,19 +417,21 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: {encryption}, } + console.log("LALALs", JSON.stringify(resp)) break } case "offer": if (activeCall) { resp = {type: "error", message: "accept: call already started"} - } else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + } else if (!supportsInsertableStreams(useWorker) && command.aesKey) { resp = {type: "error", message: "accept: encryption is not supported"} } else { const offer: RTCSessionDescriptionInit = parse(command.offer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - const {media, aesKey, useWorker, iceServers, relay} = command - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker) + const {media, aesKey, iceServers, relay} = command + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection + console.log("LALALo", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(offer)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) @@ -429,6 +443,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, } } + console.log("LALALo", JSON.stringify(resp)) break case "answer": if (!pc) { @@ -440,6 +455,7 @@ const processCommand = (function () { } else { const answer: RTCSessionDescriptionInit = parse(command.answer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) + console.log("LALALa", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(answer)) addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} @@ -472,6 +488,11 @@ const processCommand = (function () { resp = {type: "ok"} } break + case "description": + localizedState = command.state + localizedDescription = command.description + resp = {type: "ok"} + break case "end": endCall() resp = {type: "ok"} @@ -494,6 +515,7 @@ const processCommand = (function () { } catch (e) { console.log(e) } + shutdownCameraAndMic() activeCall = undefined resetVideoElements() } @@ -501,6 +523,7 @@ const processCommand = (function () { function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)) + console.log("LALAL addIceCandidates", JSON.stringify(c)) } } @@ -520,7 +543,7 @@ const processCommand = (function () { async function setupEncryptionWorker(call: Call) { if (call.aesKey) { if (!call.key) call.key = await callCrypto.decodeAesKey(call.aesKey) - if (call.useWorker && !call.worker) { + if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()` call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], {type: "text/javascript"}))) call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => @@ -680,6 +703,12 @@ const processCommand = (function () { remote: HTMLMediaElement } + function shutdownCameraAndMic() { + if (activeCall?.localStream) { + activeCall.localStream.getTracks().forEach((track) => track.stop()) + } + } + function resetVideoElements() { const videos = getVideoElements() if (!videos) return @@ -706,10 +735,19 @@ const processCommand = (function () { const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() for (const t of tracks) t.enabled = enable } - return processCommand })() +function toggleMedia(s: MediaStream, media: CallMediaType): boolean { + let res = false + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() + for (const t of tracks) { + t.enabled = !t.enabled + res = t.enabled + } + return res +} + type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise interface CallCrypto { diff --git a/packages/simplex-chat-webrtc/src/desktop/call.html b/packages/simplex-chat-webrtc/src/desktop/call.html new file mode 100644 index 0000000000..5e945ffe64 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/call.html @@ -0,0 +1,50 @@ + + + + SimpleX Chat WebRTC call + + + + + + + +
+
+

+

+
+
+ +
+

+ + + + +

+ +
+ + +
+ diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg new file mode 100644 index 0000000000..34c409818a --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg new file mode 100644 index 0000000000..afebf258d3 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg new file mode 100644 index 0000000000..941dc182a0 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg new file mode 100644 index 0000000000..43cfd7cb9f --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg new file mode 100644 index 0000000000..def80d4719 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg new file mode 100644 index 0000000000..07557e277e --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg new file mode 100644 index 0000000000..19999e82af --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg new file mode 100644 index 0000000000..2857a913f9 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/style.css b/packages/simplex-chat-webrtc/src/desktop/style.css new file mode 100644 index 0000000000..24c31fa6f7 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/style.css @@ -0,0 +1,127 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 20%; + max-width: 20%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} + +#manage-call { + position: absolute; + width: fit-content; + top: 90%; + left: 50%; + transform: translate(-50%, 0); + display: grid; + grid-auto-flow: column; + grid-column-gap: 30px; +} + +#manage-call button { + border: none; + cursor: pointer; + appearance: none; + background-color: inherit; +} + +#progress { + position: absolute; + left: 50%; + top: 50%; + margin-left: -52px; + margin-top: -52px; + border-radius: 50%; + border-top: 5px solid white; + border-right: 5px solid white; + border-bottom: 5px solid white; + border-left: 5px solid black; + width: 100px; + height: 100px; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#info-block { + position: absolute; + color: white; + line-height: 10px; + opacity: 0.8; + width: 200px; + font-family: Arial, Helvetica, sans-serif; +} + +#info-block.audio { + text-align: center; + left: 50%; + top: 50%; + margin-left: -100px; + margin-top: 100px; +} + +#info-block.video { + left: 16px; + top: 2px; +} + +#audio-call-icon { + position: absolute; + display: none; + left: 50%; + top: 50%; + margin-left: -50px; + margin-top: -44px; + width: 100px; + height: 100px; +} diff --git a/packages/simplex-chat-webrtc/src/desktop/ui.ts b/packages/simplex-chat-webrtc/src/desktop/ui.ts new file mode 100644 index 0000000000..ea681b4ebd --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/ui.ts @@ -0,0 +1,87 @@ +// Override defaults to enable worker on Chrome and Safari +useWorker = (window as any).safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1 + +// Create WebSocket connection. +const socket = new WebSocket(`ws://${location.host}`) + +socket.addEventListener("open", (_event) => { + console.log("Opened socket") + sendMessageToNative = (msg: WVApiMessage) => { + console.log("Message to server: ", msg) + socket.send(JSON.stringify(msg)) + } +}) + +socket.addEventListener("message", (event) => { + const parsed = JSON.parse(event.data) + reactOnMessageFromServer(parsed) + processCommand(parsed) + console.log("Message from server: ", event.data) +}) + +socket.addEventListener("close", (_event) => { + console.log("Closed socket") + sendMessageToNative = (_msg: WVApiMessage) => { + console.log("Tried to send message to native but the socket was closed already") + } + window.close() +}) + +function endCallManually() { + sendMessageToNative({resp: {type: "end"}}) +} + +function toggleAudioManually() { + if (activeCall?.localMedia) { + document.getElementById("toggle-audio")!!.innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio) + ? '' + : '' + } +} + +function toggleSpeakerManually() { + if (activeCall?.remoteStream) { + document.getElementById("toggle-speaker")!!.innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio) + ? '' + : '' + } +} + +function toggleVideoManually() { + if (activeCall?.localMedia) { + document.getElementById("toggle-video")!!.innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Video) + ? '' + : '' + } +} + +function reactOnMessageFromServer(msg: WVApiMessage) { + switch (msg.command?.type) { + case "capabilities": + document.getElementById("info-block")!!.className = msg.command.media + break + case "offer": + case "start": + document.getElementById("toggle-audio")!!.style.display = "inline-block" + document.getElementById("toggle-speaker")!!.style.display = "inline-block" + if (msg.command.media == "video") { + document.getElementById("toggle-video")!!.style.display = "inline-block" + } + document.getElementById("info-block")!!.className = msg.command.media + break + case "description": + updateCallInfoView(msg.command.state, msg.command.description) + if (activeCall?.connection.connectionState == "connected") { + document.getElementById("progress")!.style.display = "none" + if (document.getElementById("info-block")!!.className == CallMediaType.Audio) { + document.getElementById("audio-call-icon")!.style.display = "block" + } + } + break + } +} + +function updateCallInfoView(state: string, description: string) { + document.getElementById("state")!!.innerText = state + document.getElementById("description")!!.innerText = description +} From f026a38a75ebedb209f28f276cae78af98942cc6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 19:22:46 +0100 Subject: [PATCH 104/294] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 54 ++++++++-------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2a90e75d85..92b55fd53e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -16,7 +16,6 @@ 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415323A4082FC92887F906 /* WebRTCClient.swift */; }; 18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */; }; 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; }; - 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; }; 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; @@ -85,11 +84,6 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */; }; - 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0112AD746C8001FD661 /* libgmpxx.a */; }; - 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0122AD746C8001FD661 /* libffi.a */; }; - 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; }; - 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; }; - 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0152AD746C8001FD661 /* libgmp.a */; }; 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; }; 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; }; 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; @@ -123,6 +117,11 @@ 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; + 5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */; }; + 5CD089322AE59CB300669208 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892D2AE59CB300669208 /* libffi.a */; }; + 5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892E2AE59CB300669208 /* libgmpxx.a */; }; + 5CD089342AE59CB300669208 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892F2AE59CB300669208 /* libgmp.a */; }; + 5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -176,11 +175,6 @@ 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; - 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */; }; - 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; }; - 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; }; - 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C812AD6B6B900B21C4C /* libffi.a */; }; - 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; @@ -262,7 +256,6 @@ 18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallViewRenderers.swift; sourceTree = ""; }; 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = ""; }; - 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../multiplatform/android/src/main/assets/www; sourceTree = ""; }; 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; @@ -363,11 +356,6 @@ 5CA85D0A297218AA0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 5CA85D0C297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = "it.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CA85D0D297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - 5CA8D0112AD746C8001FD661 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CA8D0122AD746C8001FD661 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; }; - 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; }; - 5CA8D0152AD746C8001FD661 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5CAB912529E93F9400F34A95 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 5CAC41182A192D8400C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 5CAC411A2A192DE800C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = "ja.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; @@ -409,6 +397,11 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; + 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a"; sourceTree = ""; }; + 5CD0892D2AE59CB300669208 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CD0892E2AE59CB300669208 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CD0892F2AE59CB300669208 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a"; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -463,11 +456,6 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; - 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; }; - 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; }; - 64AB9C812AD6B6B900B21C4C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; @@ -517,13 +505,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, - 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */, - 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, + 5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */, + 5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */, + 5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */, + 5CD089342AE59CB300669208 /* libgmp.a in Frameworks */, + 5CD089322AE59CB300669208 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -584,11 +572,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 64AB9C812AD6B6B900B21C4C /* libffi.a */, - 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */, - 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */, - 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, - 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, + 5CD0892D2AE59CB300669208 /* libffi.a */, + 5CD0892F2AE59CB300669208 /* libgmp.a */, + 5CD0892E2AE59CB300669208 /* libgmpxx.a */, + 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */, + 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */, ); path = Libraries; sourceTree = ""; @@ -648,7 +636,6 @@ isa = PBXGroup; children = ( 5C55A92D283D0FDE00C4E99E /* sounds */, - 3C714779281C0F6800CB4D4B /* www */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, @@ -1060,7 +1047,6 @@ buildActionMask = 2147483647; files = ( 5C55A92E283D0FDE00C4E99E /* sounds in Resources */, - 3C71477A281C0F6800CB4D4B /* www in Resources */, 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, From 795c54343a02d9b50a4a9e15a6cdf05ae70e74c5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 20:51:08 +0100 Subject: [PATCH 105/294] android, desktop: reduce browser call logs, check Worker availability directly (#3256) --- .../commonMain/resources/assets/www/call.js | 23 +++++++++---------- .../resources/assets/www/desktop/ui.js | 6 ++--- packages/simplex-chat-webrtc/src/call.ts | 21 +++++++++-------- .../simplex-chat-webrtc/src/desktop/ui.ts | 6 ++--- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index fd574d0225..62c9ca7fbe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -84,10 +84,8 @@ const processCommand = (function () { if (delay) clearTimeout(delay); resolved = true; - console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)); - //const ipv6Elem = candidates.find((item) => item.candidate.includes("raddr ::")) - //candidates = ipv6Elem != undefined ? candidates.filter((elem) => elem == ipv6Elem) : candidates - //console.log("LALAL resolveIceCandidates2", JSON.stringify(candidates)) + // console.log("resolveIceCandidates", JSON.stringify(candidates)) + console.log("resolveIceCandidates"); const iceCandidates = serialize(candidates); candidates = []; resolve(iceCandidates); @@ -95,7 +93,8 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return; - console.log("LALAL sendIceCandidates", JSON.stringify(candidates)); + // console.log("sendIceCandidates", JSON.stringify(candidates)) + console.log("sendIceCandidates"); const iceCandidates = serialize(candidates); candidates = []; sendMessageToNative({ resp: { type: "ice", iceCandidates } }); @@ -217,7 +216,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: { encryption }, }; - console.log("LALALs", JSON.stringify(resp)); + // console.log("offer response", JSON.stringify(resp)) break; } case "offer": @@ -233,7 +232,7 @@ const processCommand = (function () { const { media, aesKey, iceServers, relay } = command; activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; - console.log("LALALo", JSON.stringify(remoteIceCandidates)); + // console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); @@ -245,7 +244,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, }; } - console.log("LALALo", JSON.stringify(resp)); + // console.log("answer response", JSON.stringify(resp)) break; case "answer": if (!pc) { @@ -260,7 +259,7 @@ const processCommand = (function () { else { const answer = parse(command.answer); const remoteIceCandidates = parse(command.iceCandidates); - console.log("LALALa", JSON.stringify(remoteIceCandidates)); + // console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(answer)); addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; @@ -333,7 +332,7 @@ const processCommand = (function () { function addIceCandidates(conn, iceCandidates) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)); - console.log("LALAL addIceCandidates", JSON.stringify(c)); + // console.log("addIceCandidates", JSON.stringify(c)) } } async function setupMediaStreams(call) { @@ -356,8 +355,8 @@ const processCommand = (function () { if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`; call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }))); - call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message })); - call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data })); + call.worker.onerror = ({ error, filename, lineno, message }) => console.log({ error, filename, lineno, message }); + // call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) } } } diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js index 73c33ae911..2514a24117 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -1,12 +1,12 @@ "use strict"; // Override defaults to enable worker on Chrome and Safari -useWorker = window.safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1; +useWorker = typeof window.Worker !== "undefined"; // Create WebSocket connection. const socket = new WebSocket(`ws://${location.host}`); socket.addEventListener("open", (_event) => { console.log("Opened socket"); sendMessageToNative = (msg) => { - console.log("Message to server: ", msg); + console.log("Message to server"); socket.send(JSON.stringify(msg)); }; }); @@ -14,7 +14,7 @@ socket.addEventListener("message", (event) => { const parsed = JSON.parse(event.data); reactOnMessageFromServer(parsed); processCommand(parsed); - console.log("Message from server: ", event.data); + console.log("Message from server"); }); socket.addEventListener("close", (_event) => { console.log("Closed socket"); diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index a6f036eb30..2a55b26717 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -285,7 +285,8 @@ const processCommand = (function () { function resolveIceCandidates() { if (delay) clearTimeout(delay) resolved = true - console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)) + // console.log("resolveIceCandidates", JSON.stringify(candidates)) + console.log("resolveIceCandidates") const iceCandidates = serialize(candidates) candidates = [] resolve(iceCandidates) @@ -293,7 +294,8 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return - console.log("LALAL sendIceCandidates", JSON.stringify(candidates)) + // console.log("sendIceCandidates", JSON.stringify(candidates)) + console.log("sendIceCandidates") const iceCandidates = serialize(candidates) candidates = [] sendMessageToNative({resp: {type: "ice", iceCandidates}}) @@ -417,7 +419,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: {encryption}, } - console.log("LALALs", JSON.stringify(resp)) + // console.log("offer response", JSON.stringify(resp)) break } case "offer": @@ -431,7 +433,7 @@ const processCommand = (function () { const {media, aesKey, iceServers, relay} = command activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection - console.log("LALALo", JSON.stringify(remoteIceCandidates)) + // console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(offer)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) @@ -443,7 +445,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, } } - console.log("LALALo", JSON.stringify(resp)) + // console.log("answer response", JSON.stringify(resp)) break case "answer": if (!pc) { @@ -455,7 +457,7 @@ const processCommand = (function () { } else { const answer: RTCSessionDescriptionInit = parse(command.answer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - console.log("LALALa", JSON.stringify(remoteIceCandidates)) + // console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(answer)) addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} @@ -523,7 +525,7 @@ const processCommand = (function () { function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)) - console.log("LALAL addIceCandidates", JSON.stringify(c)) + // console.log("addIceCandidates", JSON.stringify(c)) } } @@ -546,9 +548,8 @@ const processCommand = (function () { if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()` call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], {type: "text/javascript"}))) - call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => - console.log(JSON.stringify({error, filename, lineno, message})) - call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) + call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => console.log({error, filename, lineno, message}) + // call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) } } } diff --git a/packages/simplex-chat-webrtc/src/desktop/ui.ts b/packages/simplex-chat-webrtc/src/desktop/ui.ts index ea681b4ebd..f72adffd9f 100644 --- a/packages/simplex-chat-webrtc/src/desktop/ui.ts +++ b/packages/simplex-chat-webrtc/src/desktop/ui.ts @@ -1,5 +1,5 @@ // Override defaults to enable worker on Chrome and Safari -useWorker = (window as any).safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1 +useWorker = typeof window.Worker !== "undefined" // Create WebSocket connection. const socket = new WebSocket(`ws://${location.host}`) @@ -7,7 +7,7 @@ const socket = new WebSocket(`ws://${location.host}`) socket.addEventListener("open", (_event) => { console.log("Opened socket") sendMessageToNative = (msg: WVApiMessage) => { - console.log("Message to server: ", msg) + console.log("Message to server") socket.send(JSON.stringify(msg)) } }) @@ -16,7 +16,7 @@ socket.addEventListener("message", (event) => { const parsed = JSON.parse(event.data) reactOnMessageFromServer(parsed) processCommand(parsed) - console.log("Message from server: ", event.data) + console.log("Message from server") }) socket.addEventListener("close", (_event) => { From 0bd59364fd3431eba6913263e1222ee8b8b15206 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 22:43:23 +0100 Subject: [PATCH 106/294] 5.4.0-beta.2: iOS 180, Android 159, Desktop 15 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 ++++++------ apps/multiplatform/gradle.properties | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 92b55fd53e..fafb43145c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1482,7 +1482,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1524,7 +1524,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1604,7 +1604,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1636,7 +1636,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1668,7 +1668,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1714,7 +1714,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index e37dbca8eb..3474208140 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4-beta.0 -android.version_code=156 +android.version_name=5.4-beta.2 +android.version_code=159 -desktop.version_name=5.4-beta.0 -desktop.version_code=12 +desktop.version_name=5.4-beta.2 +desktop.version_code=15 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From e1bd6a93af3bc4b99074d2eef771ae2dbed2f43d Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 23 Oct 2023 15:44:04 +0300 Subject: [PATCH 107/294] use multicast address for announce (#3241) * use multicast address for announce * Add explicit multicast group membership * join multicast group on a correct side --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- simplex-chat.cabal | 1 + src/Simplex/Chat/Remote/Discovery.hs | 43 ++++++++++++++++++--------- src/Simplex/Chat/Remote/Multicast.hsc | 43 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 src/Simplex/Chat/Remote/Multicast.hsc diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 86e97eabff..f061f8ac86 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -128,6 +128,7 @@ library Simplex.Chat.Protocol Simplex.Chat.Remote Simplex.Chat.Remote.Discovery + Simplex.Chat.Remote.Multicast Simplex.Chat.Remote.Protocol Simplex.Chat.Remote.Types Simplex.Chat.Store diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index 5630c540da..babc65e6a8 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -29,6 +29,7 @@ import Data.String (IsString) import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP +import Simplex.Chat.Remote.Multicast (setMembership) import Simplex.Chat.Remote.Types (Tasks, registerAsync) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) @@ -43,15 +44,15 @@ import Simplex.Messaging.Util (ifM, tshow, whenM) import UnliftIO import UnliftIO.Concurrent --- | Link-local broadcast address. -pattern BROADCAST_ADDR_V4 :: (IsString a, Eq a) => a -pattern BROADCAST_ADDR_V4 = "0.0.0.0" +-- | mDNS multicast group +pattern MULTICAST_ADDR_V4 :: (IsString a, Eq a) => a +pattern MULTICAST_ADDR_V4 = "224.0.0.251" pattern ANY_ADDR_V4 :: (IsString a, Eq a) => a pattern ANY_ADDR_V4 = "0.0.0.0" -pattern BROADCAST_PORT :: (IsString a, Eq a) => a -pattern BROADCAST_PORT = "5226" +pattern DISCOVERY_PORT :: (IsString a, Eq a) => a +pattern DISCOVERY_PORT = "5226" -- | Announce tls server, wait for connection and attach http2 client to it. -- @@ -88,16 +89,17 @@ announceRevHTTP2 tasks invite credentials finishAction = do -- | Broadcast invite with link-local datagrams runAnnouncer :: ByteString -> IO () runAnnouncer inviteBS = do - bracket (UDP.clientSocket BROADCAST_ADDR_V4 BROADCAST_PORT False) UDP.close $ \sock -> do - N.setSocketOption (UDP.udpSocket sock) N.Broadcast 1 - N.setSocketOption (UDP.udpSocket sock) N.ReuseAddr 1 + bracket (UDP.clientSocket MULTICAST_ADDR_V4 DISCOVERY_PORT False) UDP.close $ \sock -> do + let raw = UDP.udpSocket sock + N.setSocketOption raw N.Broadcast 1 + N.setSocketOption raw N.ReuseAddr 1 forever $ do UDP.send sock inviteBS threadDelay 1000000 -- XXX: Do we need to start multiple TLS servers for different mobile hosts? startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) -startTLSServer started credentials = async . liftIO . runTransportServer started BROADCAST_PORT serverParams defaultTransportServerConfig +startTLSServer started credentials = async . liftIO . runTransportServer started DISCOVERY_PORT serverParams defaultTransportServerConfig where serverParams = def @@ -115,21 +117,34 @@ runHTTP2Client finishedVar clientVar tls = (logError "HTTP2 session already started on this listener") where attachClient = do - client <- attachHTTP2Client config ANY_ADDR_V4 BROADCAST_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls + client <- attachHTTP2Client config ANY_ADDR_V4 DISCOVERY_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls putMVar clientVar client readMVar finishedVar -- TODO connection timeout config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} withListener :: (MonadUnliftIO m) => (UDP.ListenSocket -> m a) -> m a -withListener = bracket openListener (liftIO . UDP.stop) +withListener = bracket openListener closeListener openListener :: (MonadIO m) => m UDP.ListenSocket openListener = liftIO $ do - sock <- UDP.serverSocket (ANY_ADDR_V4, read BROADCAST_PORT) - N.setSocketOption (UDP.listenSocket sock) N.Broadcast 1 + sock <- UDP.serverSocket (MULTICAST_ADDR_V4, read DISCOVERY_PORT) + logDebug $ "Discovery listener socket: " <> tshow sock + let raw = UDP.listenSocket sock + N.setSocketOption raw N.Broadcast 1 + void $ setMembership raw (listenerHostAddr4 sock) True pure sock +closeListener :: MonadIO m => UDP.ListenSocket -> m () +closeListener sock = liftIO $ do + UDP.stop sock + void $ setMembership (UDP.listenSocket sock) (listenerHostAddr4 sock) False + +listenerHostAddr4 :: UDP.ListenSocket -> N.HostAddress +listenerHostAddr4 sock = case UDP.mySockAddr sock of + N.SockAddrInet _port host -> host + _ -> error "MULTICAST_ADDR_V4 is V4" + recvAnnounce :: (MonadIO m) => UDP.ListenSocket -> m (N.SockAddr, ByteString) recvAnnounce sock = liftIO $ do (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock @@ -139,7 +154,7 @@ connectRevHTTP2 :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (HTTP2Requ connectRevHTTP2 host fingerprint = connectTLSClient host fingerprint . attachHTTP2Server connectTLSClient :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a -connectTLSClient host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host BROADCAST_PORT (Just caFingerprint) +connectTLSClient host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host DISCOVERY_PORT (Just caFingerprint) attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () attachHTTP2Server processRequest tls = do diff --git a/src/Simplex/Chat/Remote/Multicast.hsc b/src/Simplex/Chat/Remote/Multicast.hsc new file mode 100644 index 0000000000..ea015c18e3 --- /dev/null +++ b/src/Simplex/Chat/Remote/Multicast.hsc @@ -0,0 +1,43 @@ +module Simplex.Chat.Remote.Multicast (setMembership) where + +import Foreign (Ptr, allocaBytes, castPtr, pokeByteOff) +import Foreign.C.Types (CInt (..)) +import Network.Socket + +#include + +{- | Toggle multicast group membership. + +NB: Group membership is per-host, not per-process. A socket is only used to access system interface for groups. +-} +setMembership :: Socket -> HostAddress -> Bool -> IO Bool +setMembership sock group membership = allocaBytes #{size struct ip_mreq} $ \mReqPtr -> do + #{poke struct ip_mreq, imr_multiaddr} mReqPtr group + #{poke struct ip_mreq, imr_interface} mReqPtr (0 :: HostAddress) -- attempt to contact the group on ANY interface + withFdSocket sock $ \fd -> + (/= 0) <$> c_setsockopt fd c_IPPROTO_IP flag (castPtr mReqPtr) (#{size struct ip_mreq}) + where + flag = if membership then c_IP_ADD_MEMBERSHIP else c_IP_DROP_MEMBERSHIP + +#ifdef mingw32_HOST_OS + +foreign import stdcall unsafe "setsockopt" + c_setsockopt :: CInt -> CInt -> CInt -> Ptr CInt -> CInt -> IO CInt + +c_IP_ADD_MEMBERSHIP, c_IP_DROP_MEMBERSHIP :: CInt +c_IP_ADD_MEMBERSHIP = 12 +c_IP_DROP_MEMBERSHIP = 13 + +#else + +foreign import ccall unsafe "setsockopt" + c_setsockopt :: CInt -> CInt -> CInt -> Ptr CInt -> CInt -> IO CInt + +c_IP_ADD_MEMBERSHIP, c_IP_DROP_MEMBERSHIP :: CInt +c_IP_ADD_MEMBERSHIP = #const IP_ADD_MEMBERSHIP +c_IP_DROP_MEMBERSHIP = #const IP_DROP_MEMBERSHIP + +#endif + +c_IPPROTO_IP :: CInt +c_IPPROTO_IP = #const IPPROTO_IP From 6eb09625ab311ebb2513d9e7f98263f59a98ba91 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 23 Oct 2023 20:53:12 +0100 Subject: [PATCH 108/294] website: update copy --- website/langs/en.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/langs/en.json b/website/langs/en.json index 434aed8343..1bb64c7efa 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -37,12 +37,12 @@ "hero-overlay-2-title": "Why user IDs are bad for privacy?", "hero-overlay-3-title": "Security assessment", "feature-1-title": "E2E-encrypted messages with markdown and editing", - "feature-2-title": "E2E-encrypted
images and files", - "feature-3-title": "Decentralized secret groups —
only users know they exist", + "feature-2-title": "E2E-encrypted
images, videos and files", + "feature-3-title": "E2E-encrypted decentralized groups — only users know they exist", "feature-4-title": "E2E-encrypted voice messages", "feature-5-title": "Disappearing messages", "feature-6-title": "E2E-encrypted
audio and video calls", - "feature-7-title": "Portable encrypted database — move your profile to another device", + "feature-7-title": "Portable encrypted app storage — move profile to another device", "feature-8-title": "Incognito mode —
unique to SimpleX Chat", "simplex-network-overlay-1-title": "Comparison with P2P messaging protocols", "simplex-private-1-title": "2-layers of
end-to-end encryption", From 66d8bb94d6d18df4e42690951ff4c41c2e13128e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 23 Oct 2023 21:16:36 +0100 Subject: [PATCH 109/294] website: downloads page --- docs/DOWNLOADS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 64b76e7fec..94b8d9197e 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -7,7 +7,7 @@ revision: 01.10.2023 | Updated 01.10.2023 | Languages: EN | # Download SimpleX apps -The latest stable version is v5.3.1. +The latest stable version is v5.3.2. You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). @@ -21,9 +21,9 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.0/simplex-desktop-windows-x86-64.msi) (BETA). @@ -31,14 +31,14 @@ Using the same profile as on mobile device is not yet supported – you need to **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-windows-x86-64). From ed1eef7362a1f21647d20628d97eab50fe928220 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:38:16 +0400 Subject: [PATCH 110/294] core: update simplexmq (inv locks) (#3274) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 8a05c0bb95..eb557e52f5 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc + tag: 55a6157880396be899c010f880a42322cf65258a source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 60b9505fea..6c41ff2a1a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc" = "0xcbvxz2nszm1sdh6gvmfzjf9n2ldsarmmzbl6j6b5hg9i1mppc6"; + "https://github.com/simplex-chat/simplexmq.git"."55a6157880396be899c010f880a42322cf65258a" = "1fhhyi2060pp72izrqki6gazb759hcv9wypxf39jkwpqpvrn81hv"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/stack.yaml b/stack.yaml index 99b9d179cd..e6f6f49b76 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc + commit: 55a6157880396be899c010f880a42322cf65258a - github: kazu-yamamoto/http2 commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher From f8332bac7f624d8003b9613ef425f0cf82b2d10b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 24 Oct 2023 18:13:19 +0400 Subject: [PATCH 111/294] core: take chat lock earlier when joining group (#3272) --- src/Simplex/Chat.hs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3581b80c29..c4b34666d5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1547,12 +1547,12 @@ processChatCommand = \case Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do - (invitation, ct) <- withStore $ \db -> do - inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId - (inv,) <$> getContactViaMember db user fromMember - let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation - Contact {activeConn = Connection {peerChatVRange}} = ct withChatLock "joinGroup" . procCmd $ do + (invitation, ct) <- withStore $ \db -> do + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId + (inv,) <$> getContactViaMember db user fromMember + let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation + Contact {activeConn = Connection {peerChatVRange}} = ct subMode <- chatReadVar subscriptionMode dm <- directMessage $ XGrpAcpt membership.memberId agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode From 239765e482b837f8dabbd82e74735713a915ea7e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 24 Oct 2023 20:59:06 +0400 Subject: [PATCH 112/294] core: create new group with incognito membership (#3277) --- src/Simplex/Chat.hs | 14 +++++---- src/Simplex/Chat/Controller.hs | 4 +-- src/Simplex/Chat/Store/Direct.hs | 11 ------- src/Simplex/Chat/Store/Groups.hs | 7 +++-- src/Simplex/Chat/Store/Shared.hs | 11 +++++++ src/Simplex/Chat/View.hs | 25 +++++++++++----- tests/ChatTests/Groups.hs | 51 ++++++++++++++++++++++++++++++++ tests/ChatTests/Profiles.hs | 2 +- 8 files changed, 95 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c4b34666d5..c70c842c04 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1510,13 +1510,15 @@ processChatCommand = \case chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg processChatCommand $ APIChatItemReaction chatRef chatItemId add reaction - APINewGroup userId gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do + APINewGroup userId incognito gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do checkValidName displayName gVar <- asks idsDrg - groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile + -- [incognito] generate incognito profile for group membership + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile incognitoProfile pure $ CRGroupCreated user groupInfo - NewGroup gProfile -> withUser $ \User {userId} -> - processChatCommand $ APINewGroup userId gProfile + NewGroup incognito gProfile -> withUser $ \User {userId} -> + processChatCommand $ APINewGroup userId incognito gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId @@ -5714,8 +5716,8 @@ chatCommandP = ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, ("/help" <|> "/h") $> ChatHelp HSMain, - ("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile), - "/_group " *> (APINewGroup <$> A.decimal <* A.space <*> jsonP), + ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), + "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRMember)), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d8851ad87a..74501ad1e5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -363,8 +363,8 @@ data ChatCommand | EditMessage {chatName :: ChatName, editedMsg :: Text, message :: Text} | UpdateLiveMessage {chatName :: ChatName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: Text} | ReactToMessage {add :: Bool, reaction :: MsgReaction, chatName :: ChatName, reactToMessage :: Text} - | APINewGroup UserId GroupProfile - | NewGroup GroupProfile + | APINewGroup UserId IncognitoEnabled GroupProfile + | NewGroup IncognitoEnabled GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup GroupName | MemberRole GroupName ContactName GroupMemberRole diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 563cc337e0..477361acdf 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -193,17 +193,6 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 -createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, image} = do - DB.execute - db - [sql| - INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) - VALUES (?,?,?,?,?,?,?) - |] - (displayName, fullName, image, userId, Just True, createdAt, createdAt) - insertedRowId db - createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact createDirectContact db user@User {userId} activeConn@Connection {connId, localAlias} p@Profile {preferences} = do createdAt <- liftIO getCurrentTime diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 76d68cc6be..0b296b17e8 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -283,11 +283,12 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo -createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do +createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo +createNewGroup db gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime + customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do groupId <- liftIO $ do DB.execute @@ -301,7 +302,7 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do (ldn, userId, profileId, True, currentTs, currentTs, currentTs) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 - membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs + membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs} diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 2a90b54d7f..2ad447aa8e 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -184,6 +184,17 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange where ent ct = if connType == ct then entityId else Nothing +createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 +createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, image} = do + DB.execute + db + [sql| + INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?) + |] + (displayName, fullName, image, userId, Just True, createdAt, createdAt) + insertedRowId db + setPeerChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () setPeerChatVRange db connId (VersionRange minVer maxVer) = DB.execute diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 07cbec601f..7f1e1f5c7a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -132,7 +132,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRUserContactLink u UserContactLink {connReqContact, autoAccept} -> ttyUser u $ connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept CRUserContactLinkUpdated u UserContactLink {autoAccept} -> ttyUser u $ autoAcceptStatus_ autoAccept CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] - CRGroupCreated u g -> ttyUser u $ viewGroupCreated g + CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRGroupsList u gs -> ttyUser u $ viewGroupsList gs CRSentGroupInvitation u g c _ -> @@ -792,11 +792,22 @@ viewReceivedContactRequest c Profile {fullName} = "to reject: " <> highlight ("/rc " <> viewName c) <> " (the sender will NOT be notified)" ] -viewGroupCreated :: GroupInfo -> [StyledString] -viewGroupCreated g = - [ "group " <> ttyFullGroup g <> " is created", - "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) - ] +viewGroupCreated :: GroupInfo -> Bool -> [StyledString] +viewGroupCreated g testView = + case incognitoMembershipProfile g of + Just localProfile + | testView -> incognitoProfile' profile : message + | otherwise -> message + where + profile = fromLocalProfile localProfile + message = + [ "group " <> ttyFullGroup g <> " is created, your incognito profile for this group is " <> incognitoProfile' profile, + "to add members use " <> highlight ("/create link #" <> viewGroupName g) + ] + Nothing -> + [ "group " <> ttyFullGroup g <> " is created", + "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) + ] viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] viewCannotResendInvitation g c = @@ -1672,7 +1683,7 @@ viewChatError logLevel = \case _ -> ": you have insufficient permissions for this action, the required role is " <> plain (strEncode role) CEGroupMemberInitialRole g role -> [ttyGroup' g <> ": initial role for group member cannot be " <> plain (strEncode role) <> ", use member or observer"] CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"] - CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"] + CEGroupIncognitoCantInvite -> ["you are using an incognito profile for this group - prohibited to invite contacts"] CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"] CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> viewGroupName g)] CEGroupMemberNotActive -> ["your group connection is not active yet, try later"] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 7a8b1368bf..97b7491067 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -23,6 +23,7 @@ chatGroupTests = do describe "chat groups" $ do it "add contacts, create group and send/receive messages" testGroup it "add contacts, create group and send/receive messages, check messages" testGroupCheckMessages + it "create group with incognito membership" testNewGroupIncognito it "create and join group with 4 members" testGroup2 it "create and delete group" testGroupDelete it "create group with the same displayName" testGroupSameName @@ -277,6 +278,56 @@ testGroupShared alice bob cath checkMessages = do alice #$> ("/_unread chat #1 on", id, "ok") alice #$> ("/_unread chat #1 off", id, "ok") +testNewGroupIncognito :: HasCallStack => FilePath -> IO () +testNewGroupIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + -- alice creates group with incognito membership + alice ##> "/g i team" + aliceIncognito <- getTermLine alice + alice <## ("group #team is created, your incognito profile for this group is " <> aliceIncognito) + alice <## "to add members use /create link #team" + + -- alice invites bob + alice ##> "/a team bob" + alice <## "you are using an incognito profile for this group - prohibited to invite contacts" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + _ <- getTermLine alice + concurrentlyN_ + [ do + alice <## ("bob_1 (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob_1 to print out this incognito profile again" + alice <## "bob_1 invited to group #team via your group link" + alice <## "#team: bob_1 joined the group", + do + bob <## (aliceIncognito <> ": contact is connected") + bob <## "#team: you joined the group" + ] + + alice <##> bob + + alice ?#> "@bob_1 hi, I'm incognito" + bob <# (aliceIncognito <> "> hi, I'm incognito") + bob #> ("@" <> aliceIncognito <> " hey, I'm bob") + alice ?<# "bob_1> hey, I'm bob" + + alice ?#> "#team hello" + bob <# ("#team " <> aliceIncognito <> "> hello") + bob #> "#team hi there" + alice ?<# "#team bob_1> hi there" + + alice ##> "/gs" + alice <## "i #team (2 members)" + bob ##> "/gs" + bob <## "#team (2 members)" + testGroup2 :: HasCallStack => FilePath -> IO () testGroup2 = testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 68b925342e..d806290d6e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1085,7 +1085,7 @@ testJoinGroupIncognito = ] -- cath cannot invite to the group because her membership is incognito cath ##> "/a secret_club dan" - cath <## "you've connected to this group using an incognito profile - prohibited to invite contacts" + cath <## "you are using an incognito profile for this group - prohibited to invite contacts" -- alice invites dan alice ##> "/a secret_club dan admin" concurrentlyN_ From b58d61c3397f669bde0e0f34e482227ca425f0f3 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Oct 2023 04:27:58 +0800 Subject: [PATCH 113/294] android: delete files after sharing correctly (#3264) --- .../simplex/common/views/chat/ComposeView.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 972bc6621f..84a5879b2b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -324,8 +324,26 @@ fun ComposeView( } fun deleteUnusedFiles() { - chatModel.filesToDelete.forEach { it.delete() } - chatModel.filesToDelete.clear() + val shared = chatModel.sharedContent.value + if (shared == null) { + chatModel.filesToDelete.forEach { it.delete() } + chatModel.filesToDelete.clear() + } else { + val sharedPaths = when (shared) { + is SharedContent.Media -> shared.uris.map { it.toString() } + is SharedContent.File -> listOf(shared.uri.toString()) + is SharedContent.Text -> emptyList() + } + // When sharing a file and pasting it in SimpleX itself, the file shouldn't be deleted before sending or before leaving the chat after sharing + chatModel.filesToDelete.removeAll { file -> + if (sharedPaths.any { it.endsWith(file.name) }) { + false + } else { + file.delete() + true + } + } + } } suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { From 10f79aae6608a72b285f9cbdb121104aed60b48a Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Oct 2023 04:39:43 +0800 Subject: [PATCH 114/294] android: alert on unsupported file path when sharing (#3265) * android: alert on unsupported file path when sharing * update text --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../src/main/java/chat/simplex/app/MainActivity.kt | 11 +++++++++++ .../src/commonMain/resources/MR/base/strings.xml | 2 ++ 2 files changed, 13 insertions(+) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 2dff3d604b..19305c2e51 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -143,6 +143,7 @@ fun processExternalIntent(intent: Intent?) { val text = intent.getStringExtra(Intent.EXTRA_TEXT) val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { + if (uri.scheme != "content") return showNonContentUriAlert() // Shared file that contains plain text, like `*.log` file chatModel.sharedContent.value = SharedContent.File(text ?: "", uri.toURI()) } else if (text != null) { @@ -153,12 +154,14 @@ fun processExternalIntent(intent: Intent?) { isMediaIntent(intent) -> { val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { + if (uri.scheme != "content") return showNonContentUriAlert() chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri.toURI())) } // All other mime types } else -> { val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { + if (uri.scheme != "content") return showNonContentUriAlert() chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri.toURI()) } } @@ -173,6 +176,7 @@ fun processExternalIntent(intent: Intent?) { isMediaIntent(intent) -> { val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) as? List if (uris != null) { + if (uris.any { it.scheme != "content" }) return showNonContentUriAlert() chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris.map { it.toURI() }) } // All other mime types } @@ -185,6 +189,13 @@ fun processExternalIntent(intent: Intent?) { fun isMediaIntent(intent: Intent): Boolean = intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true +private fun showNonContentUriAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.non_content_uri_alert_title), + text = generalGetString(MR.strings.non_content_uri_alert_text) + ) +} + //fun testJson() { // val str: String = """ // """.trimIndent() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 1ae85cd050..b5ffd6630f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -16,6 +16,8 @@ Opening database… + Invalid file path + You shared an invalid file path. Report the issue to the app developers. connected From 1dcd2760b0b803a9e050532c8505379db6516288 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Oct 2023 06:01:47 +0800 Subject: [PATCH 115/294] ui: show alert after saving profile with existing name (#3273) * android, desktop: show alert after saving profile with existing name * ios: show alert after saving profile with existing name * return statements * better way of showing alert --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 3 ++- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 4 ++-- apps/ios/Shared/Views/ChatList/ChatListNavLink.swift | 4 ++-- apps/ios/Shared/Views/UserSettings/UserProfile.swift | 4 +++- apps/ios/SimpleXChat/AppGroup.swift | 6 +++--- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 3 +++ .../simplex/common/views/usersettings/UserProfileView.kt | 2 +- 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 99e8c02847..680a7132df 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -720,8 +720,9 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? { let userId = try currentUserId("apiUpdateProfile") let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { - case .userProfileNoChange: return nil + case .userProfileNoChange: return (profile, []) case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts) + case .chatCmdError(_, .errorStore(.duplicateName)): return nil; default: throw r } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ec4cc0fc4c..b90c9e7479 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -242,7 +242,7 @@ struct ChatInfoView: View { } .actionSheet(isPresented: $showDeleteContactActionSheet) { if contact.ready && contact.active { - ActionSheet( + return ActionSheet( title: Text("Delete contact?\nThis cannot be undone!"), buttons: [ .destructive(Text("Delete and notify contact")) { deleteContact(notify: true) }, @@ -251,7 +251,7 @@ struct ChatInfoView: View { ] ) } else { - ActionSheet( + return ActionSheet( title: Text("Delete contact?\nThis cannot be undone!"), buttons: [ .destructive(Text("Delete")) { deleteContact() }, diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index be912d666a..088335d198 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -78,7 +78,7 @@ struct ChatListNavLink: View { .frame(height: rowHeights[dynamicTypeSize]) .actionSheet(isPresented: $showDeleteContactActionSheet) { if contact.ready && contact.active { - ActionSheet( + return ActionSheet( title: Text("Delete contact?\nThis cannot be undone!"), buttons: [ .destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } }, @@ -87,7 +87,7 @@ struct ChatListNavLink: View { ] ) } else { - ActionSheet( + return ActionSheet( title: Text("Delete contact?\nThis cannot be undone!"), buttons: [ .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index b1a362a5a1..b64ec21de6 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -174,11 +174,13 @@ struct UserProfile: View { chatModel.updateCurrentUser(newProfile) profile = newProfile } + editProfile = false + } else { + alert = .duplicateUserError } } catch { logger.error("UserProfile apiUpdateProfile error: \(responseError(error))") } - editProfile = false } } } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 4943dbd4ea..cc61fae53f 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -83,9 +83,9 @@ public enum AppState: String { public var canSuspend: Bool { switch self { - case .active: true - case .bgRefresh: true - default: false + case .active: return true + case .bgRefresh: return true + default: return false } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 58850ce056..1abc823c08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -943,6 +943,9 @@ object ChatController { val r = sendCmd(CC.ApiUpdateProfile(userId, profile)) if (r is CR.UserProfileNoChange) return profile to emptyList() if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts + if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) + } Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") return null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index 38c9e58ad2..ea4ef79d42 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -42,8 +42,8 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { val (newProfile, _) = updated chatModel.updateCurrentUser(newProfile) profile = newProfile + close() } - close() } } ) From b0f55d6de5e745bfae534765c05cb5d88c3568b3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 25 Oct 2023 10:45:36 +0400 Subject: [PATCH 116/294] core: update simplexmq (check snd queue) (#3280) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index eb557e52f5..5c19fcd446 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 55a6157880396be899c010f880a42322cf65258a + tag: d920a2504b6d4653748da7d297cb13cd0a0f1f48 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 6c41ff2a1a..26188aa774 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."55a6157880396be899c010f880a42322cf65258a" = "1fhhyi2060pp72izrqki6gazb759hcv9wypxf39jkwpqpvrn81hv"; + "https://github.com/simplex-chat/simplexmq.git"."d920a2504b6d4653748da7d297cb13cd0a0f1f48" = "0r53wn01z044h6myvd458n3hiqsz64kpv59khgybzwdw5mmqnp34"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/stack.yaml b/stack.yaml index e6f6f49b76..f0fcbab1de 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 55a6157880396be899c010f880a42322cf65258a + commit: d920a2504b6d4653748da7d297cb13cd0a0f1f48 - github: kazu-yamamoto/http2 commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher From 743597e8488e2a9e0bf918af4a0c96fecb96a3d6 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Oct 2023 16:56:59 +0800 Subject: [PATCH 117/294] ios: making message text view working better (#3279) * ios: making message text view working better * style for ternaries --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../ComposeMessage/NativeTextEditor.swift | 74 ++++++++++++++++--- .../Chat/ComposeMessage/SendMessageView.swift | 39 +++------- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 51deced72c..3eead5b0af 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -14,20 +14,28 @@ import PhotosUI struct NativeTextEditor: UIViewRepresentable { @Binding var text: String @Binding var disableEditing: Bool - let height: CGFloat - let font: UIFont + @Binding var height: CGFloat @Binding var focused: Bool let alignment: TextAlignment let onImagesAdded: ([UploadContent]) -> Void + private let minHeight: CGFloat = 37 + + private let defaultHeight: CGFloat = { + let field = CustomUITextField(height: Binding.constant(0)) + field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down) + }() + func makeUIView(context: Context) -> UITextView { - let field = CustomUITextField() + let field = CustomUITextField(height: _height) field.text = text - field.font = font field.textAlignment = alignment == .leading ? .left : .right field.autocapitalizationType = .sentences field.setOnTextChangedListener { newText, images in if !disableEditing { + // Speed up the process of updating layout, reduce jumping content on screen + if !isShortEmoji(newText) { updateHeight(field) } text = newText } else { field.text = text @@ -39,24 +47,72 @@ struct NativeTextEditor: UIViewRepresentable { field.setOnFocusChangedListener { focused = $0 } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + updateFont(field) + updateHeight(field) return field } func updateUIView(_ field: UITextView, context: Context) { field.text = text - field.font = font field.textAlignment = alignment == .leading ? .left : .right + updateFont(field) + updateHeight(field) + } + + private func updateHeight(_ field: UITextView) { + let maxHeight = min(360, field.font!.lineHeight * 12) + // When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size + let newHeight = field.text == "" + ? defaultHeight + : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down) + + if field.frame.size.height != newHeight { + field.frame.size = CGSizeMake(field.frame.size.width, newHeight) + (field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight) + } + } + + private func updateFont(_ field: UITextView) { + field.font = isShortEmoji(field.text) + ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) + : UIFont.preferredFont(forTextStyle: .body) } } private class CustomUITextField: UITextView, UITextViewDelegate { + var height: Binding + var newHeight: CGFloat = 0 var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } + init(height: Binding) { + self.height = height + super.init(frame: .zero, textContainer: nil) + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + // This func here needed because using frame.size.height in intrinsicContentSize while loading a screen with text (for example. when you have a draft), + // produces incorrect height because at that point intrinsicContentSize has old value of frame.size.height even if it was set to new value right before the call + // (who knows why...) + func invalidateIntrinsicContentHeight(_ newHeight: CGFloat) { + self.newHeight = newHeight + invalidateIntrinsicContentSize() + } + + override var intrinsicContentSize: CGSize { + if height.wrappedValue != newHeight { + DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight }) + } + return CGSizeMake(0, newHeight) + } + func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { self.onTextChanged = onTextChanged } - + func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { self.onFocusChanged = onFocusChanged } @@ -144,14 +200,14 @@ private class CustomUITextField: UITextView, UITextViewDelegate { struct NativeTextEditor_Previews: PreviewProvider{ static var previews: some View { - return NativeTextEditor( + NativeTextEditor( text: Binding.constant("Hello, world!"), disableEditing: Binding.constant(false), - height: 100, - font: UIFont.preferredFont(forTextStyle: .body), + height: Binding.constant(100), focused: Binding.constant(false), alignment: TextAlignment.leading, onImagesAdded: { _ in } ) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 6eed51788e..8f7b23c888 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -32,15 +32,12 @@ struct SendMessageView: View { var sendButtonColor = Color.accentColor @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body - @State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body) @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 @State private var showCustomDisappearingMessageDialogue = false @State private var showCustomTimePicker = false @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @State private var progressByTimeout = false - var maxHeight: CGFloat = 360 - var minHeight: CGFloat = 37 @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false var body: some View { @@ -57,30 +54,16 @@ struct SendMessageView: View { .frame(maxWidth: .infinity) } else { let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading - Text(composeState.message) - .lineLimit(10) - .font(teFont) - .multilineTextAlignment(alignment) -// put text on top (after NativeTextEditor) and set color to precisely align it on changes -// .foregroundColor(.red) - .foregroundColor(.clear) - .padding(.horizontal, 10) - .padding(.top, 8) - .padding(.bottom, 6) - .matchedGeometryEffect(id: "te", in: namespace) - .background(GeometryReader(content: updateHeight)) - NativeTextEditor( text: $composeState.message, disableEditing: $composeState.inProgress, - height: teHeight, - font: teUiFont, + height: $teHeight, focused: $keyboardVisible, alignment: alignment, onImagesAdded: onMediaAdded ) .allowsTightening(false) - .frame(height: teHeight) + .fixedSize(horizontal: false, vertical: true) } } @@ -100,11 +83,13 @@ struct SendMessageView: View { .frame(height: teHeight, alignment: .bottom) } } - + .padding(.vertical, 1) + .overlay( RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) - .frame(height: teHeight) + ) } + .onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.inProgress) { inProgress in if inProgress { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { @@ -415,16 +400,12 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 4) } - private func updateHeight(_ g: GeometryProxy) -> Color { + private func updateFont(_ text: String) { DispatchQueue.main.async { - teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) - (teFont, teUiFont) = isShortEmoji(composeState.message) - ? composeState.message.count < 4 - ? (largeEmojiFont, largeEmojiUIFont) - : (mediumEmojiFont, mediumEmojiUIFont) - : (.body, UIFont.preferredFont(forTextStyle: .body)) + teFont = isShortEmoji(text) + ? (text.count < 4 ? largeEmojiFont : mediumEmojiFont) + : .body } - return Color.clear } } From 4a8da196ad0851a5183bee5a4239e2e8bccf23e7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 25 Oct 2023 11:55:06 +0100 Subject: [PATCH 118/294] core: more permissive display name validation, only allow single quotes in CLI for the names with spaces (#3282) --- src/Simplex/Chat.hs | 18 ++++++++++++------ tests/ValidNames.hs | 22 +++++++++++++++++----- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c70c842c04..4b6359f135 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -5839,7 +5839,7 @@ chatCommandP = mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal - displayName = safeDecodeUtf8 <$> (quoted "'\"" <|> takeNameTill isSpace) + displayName = safeDecodeUtf8 <$> (quoted "'" <|> takeNameTill isSpace) where takeNameTill p = A.peekChar' >>= \c -> @@ -5954,14 +5954,20 @@ timeItToView s action = do pure a mkValidName :: String -> String -mkValidName = reverse . dropWhile isSpace . fst . foldl' addChar ("", '\NUL') +mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where - addChar (r, prev) c = if notProhibited && validChar then (c' : r, c') else (r, prev) + fst3 (x, _, _) = x + addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct) where c' = if isSpace c then ' ' else c + punct' + | isPunctuation c = punct + 1 + | isSpace c = punct + | otherwise = 0 validChar - | prev == '\NUL' || isSpace prev = validFirstChar - | isPunctuation prev = validFirstChar || isSpace c + | c == '\'' = False + | prev == '\NUL' = c > ' ' && c /= '#' && c /= '@' && validFirstChar + | isSpace prev = validFirstChar || (punct == 0 && isPunctuation c) + | isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c) | otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c validFirstChar = isLetter c || isNumber c || isSymbol c - notProhibited = c `notElem` ("@#'\"`" :: String) diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index 40cda01431..0700d80846 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -14,14 +14,26 @@ testMkValidName = do mkValidName "John Doe" `shouldBe` "John Doe" mkValidName "J.Doe" `shouldBe` "J.Doe" mkValidName "J. Doe" `shouldBe` "J. Doe" - mkValidName "J..Doe" `shouldBe` "J.Doe" - mkValidName "J ..Doe" `shouldBe` "J Doe" - mkValidName "J . . Doe" `shouldBe` "J Doe" + mkValidName "J..Doe" `shouldBe` "J..Doe" + mkValidName "J ..Doe" `shouldBe` "J ..Doe" + mkValidName "J ... Doe" `shouldBe` "J ... Doe" + mkValidName "J .... Doe" `shouldBe` "J ... Doe" + mkValidName "J . . Doe" `shouldBe` "J . Doe" mkValidName "@alice" `shouldBe` "alice" mkValidName "#alice" `shouldBe` "alice" mkValidName " alice" `shouldBe` "alice" mkValidName "alice " `shouldBe` "alice" mkValidName "John Doe" `shouldBe` "John Doe" mkValidName "'John Doe'" `shouldBe` "John Doe" - mkValidName "\"John Doe\"" `shouldBe` "John Doe" - mkValidName "`John Doe`" `shouldBe` "John Doe" + mkValidName "\"John Doe\"" `shouldBe` "John Doe\"" + mkValidName "`John Doe`" `shouldBe` "`John Doe`" + mkValidName "John \"Doe\"" `shouldBe` "John \"Doe\"" + mkValidName "John `Doe`" `shouldBe` "John `Doe`" + mkValidName "alice/bob" `shouldBe` "alice/bob" + mkValidName "alice / bob" `shouldBe` "alice / bob" + mkValidName "alice /// bob" `shouldBe` "alice /// bob" + mkValidName "alice //// bob" `shouldBe` "alice /// bob" + mkValidName "alice >>= bob" `shouldBe` "alice >>= bob" + mkValidName "alice@example.com" `shouldBe` "alice@example.com" + mkValidName "alice <> bob" `shouldBe` "alice <> bob" + mkValidName "alice -> bob" `shouldBe` "alice -> bob" From cd98fabe43579439bb93c712b768ace001174a5e Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 25 Oct 2023 18:39:46 +0300 Subject: [PATCH 119/294] robust discovery RFC (#3276) * add new discovery RFC * update * update * update ports --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- docs/rfcs/2023-10-24-robust-discovery.md | 137 +++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/rfcs/2023-10-24-robust-discovery.md diff --git a/docs/rfcs/2023-10-24-robust-discovery.md b/docs/rfcs/2023-10-24-robust-discovery.md new file mode 100644 index 0000000000..ff06a12ff7 --- /dev/null +++ b/docs/rfcs/2023-10-24-robust-discovery.md @@ -0,0 +1,137 @@ +# Robust discovery + +## Problem + +Remote session protocol has the "discovery" phase where mobile and desktop try to find each other. + +Given how easy it is to spoof UDP datagrams extra care should be taken to avoid unauthenticated data. +In the tech spike for remote sessions, a discovery datagram contains only a TLS key fingerprint. +While this is enough to operate in a safe environment, the datagram itself should be authenticated. + +Using link-local broadcast address of `255.255.255.255` is problematic on MacOS. + +The initial implementation effort shown that discovery process better be running as a stand-alone service. +Additionally, it is desirable to run multiple service announcers in parallel from a single process. +Each announced service may be a remote controller assigned to a different remote host device, or some other site-local service entirely. + +We still want to avoid system interface enumeration due to guesswork involved in filtering them and extra permissions/entitlements required on mobile devices. + +## Solution + +* An OOB data is extended with a public key to authenticate datagrams. +* A datagram is extended with MAC envelope, service address and its tag. +* A site-local multicast group is used for announcement. +* Additional site-local multicast group is used by announcer to find its own public LAN address. + +### Datagram + +- `[4]` Version range encoding +- `[1]` 'A' (Announce) +- `[8]` systemSeconds of SystemTime encoding - does not change within session. +- `[2]` Announce counter. +- `[6]` Service address (host and port). +- `[1 + 32]` SHA256 fingerprint of CA certificate used to issue per-session TLS certificates. +- `[1 + ?]` X25519 DH key encoding to agree for per-session encryption inside TLS (hello received in response to hello from controller will contain host DH key). +- `[1 + ?]` Ed25519 public key used to sign datagram (the host also will receive it in the QR code, it should match this one). +- `[1 + ?]` Ed25519 signature signing the previous bytes. + +"Encoding" here means Encoding class. + +That gives ~250 bytes (TBC) that is well under typical MTU of ~1500. + +A site-local multicast group `224.0.0.251` is used to announce services on port `5227`. + +> The same group and port are used in mDNS (AKA bonjour/avahi/dns-sd/zeroconf) so we expect it to run with most home/SOHO access points without further configuration, although using a different port can make it ineffective. + +### OOB data + +Announcer MUST include: +- ED25519 public key used to sign announce datagrams in its OOB link/QR code (also included in datagram, so they can be validated before scanning QR code). +- the CA certificate fingerprint (also included in datagram). +- device name for better initial contact UX. + +### Discovery announcer + +> announcer is run before the controller service. +> +> Multiple announcers can send to the same group/port simultaneously. + +A typical announce interval is 1 second to balance UI responsiveness with network traffic. + +Announcer MUST first discover its own address and validate with the list of local network interfaces. + +To discover it's address it will send a datagram with this format: + +- `[4]` Version range encoding +- `[1]` 'I' (Identify) +- `[1 + 32]` Random number. + +Announcer MUST NOT announce a service for a different host. + +### Implementation + +``` +ChatController { + ... + multicastSubscribers :: TMVar Int + ... +} +``` + +Controller/host connection steps: + +1. take multicastSubscribers, if 0 subscribe to multicast group +2. increase counter and put it back. +3. send SXC0 datagrams to discover own address. +4. when received, match address to network interfaces, fail if no match after a few datagrams. +5. get free port for TCP session. +6. generate DH key for session. +7. prepare and start sending signed SXC1 datagrams. +8. when host connects to the address in the announcement, stop sending datagrams. +9. take multicastSubscribers, if 1 unsubscribe from multicast group +10. put back min(0, cnt - 1). +11. send Hello to host. +12. get Hello from host with DH key, compute shared secret to be used for remaining commands and responses. + +### Service (TCP server) + +A service submits its port/tag/payload to announcer and cancels it when a client connection is established or the service is shut down. + +A service SHOULD use system-provided dynamic port (i.e. bind to port `0`) to avoid getting "address in use" errors due to multiple service instances running or another/system service running on a designated port. + +### Discovery listener + +> TBD: A listener is most certainly a singleton service. But what would its lifetime be? +> We can run it continously for a snappier discovery and no-brainer client API. +> Or we can run it on-demand, registering there requests for discovery. + +An active listener service receives datagrams and maintains a discovery table mapping service tags and keys to source addresses. +A service key is derived from the payload, which MAY be used as-is. +Source address contains both host and port. + +Listener MUST verify datagram signature against the key it got in datagram. + +Listener MUST verify that the address in the announcement matches the source address of the datagram. + +During the first connection to the new controller: + +OOB must have the same: +- Ed25519 key used to sign datagrams. +- CA cert fingerprint. + +During the subsequent connections, these keys and CA cert fingerprint in the datagram mush match the record. + +### Service (TCP client) + +> TBD: This assumes always-on listener. + +A TCP client will use STM to wait for expected service tag and key to appear in discovery table to get its address. + +E.g. a remote host on a mobile will wait for the remote profile service with a key fingerprint from OOB. + +### Finding own address with multicast + +An host with a multicast entitlement may use it to find its own address. +Receiving your own datagram would reveal source address just as it is used in (unauthenticated) discovery tests. + +The same multicast group `224.0.0.251` is used to send "mirror" datagrams on port `5227`. From 4a5fdd3e0eed0d3eae2670bc7829d9ced073d892 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 26 Oct 2023 10:32:11 +0400 Subject: [PATCH 120/294] ios, android: show progress indicator on joining group (#3281) --- .../Chat/ChatItem/CIGroupInvitationView.swift | 68 +++++++++++------ .../Views/ChatList/ChatListNavLink.swift | 58 ++++++++++----- .../Views/ChatList/ChatPreviewView.swift | 19 +++-- .../simplex/common/views/chat/ChatView.kt | 21 +++--- .../views/chat/item/CIGroupInvitationView.kt | 74 +++++++++++++------ .../common/views/chat/item/ChatItemView.kt | 6 +- .../views/chatlist/ChatListNavLinkView.kt | 71 ++++++++++++++---- .../common/views/chatlist/ChatPreviewView.kt | 38 +++++++--- 8 files changed, 252 insertions(+), 103 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index c7ec3ca713..72013877ca 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -17,34 +17,45 @@ struct CIGroupInvitationView: View { var memberRole: GroupMemberRole var chatIncognito: Bool = false @State private var frameWidth: CGFloat = 0 + @State private var inProgress = false + @State private var progressByTimeout = false var body: some View { let action = !chatItem.chatDir.sent && groupInvitation.status == .pending let v = ZStack(alignment: .bottomTrailing) { - VStack(alignment: .leading) { - groupInfoView(action) - .padding(.horizontal, 2) - .padding(.top, 8) - .padding(.bottom, 6) - .overlay(DetermineWidth()) + ZStack { + VStack(alignment: .leading) { + groupInfoView(action) + .padding(.horizontal, 2) + .padding(.top, 8) + .padding(.bottom, 6) + .overlay(DetermineWidth()) - Divider().frame(width: frameWidth) + Divider().frame(width: frameWidth) - if action { - groupInvitationText() - .overlay(DetermineWidth()) - Text(chatIncognito ? "Tap to join incognito" : "Tap to join") - .foregroundColor(chatIncognito ? .indigo : .accentColor) - .font(.callout) - .padding(.trailing, 60) - .overlay(DetermineWidth()) - } else { - groupInvitationText() - .padding(.trailing, 60) - .overlay(DetermineWidth()) + if action { + VStack(alignment: .leading, spacing: 2) { + groupInvitationText() + .overlay(DetermineWidth()) + Text(chatIncognito ? "Tap to join incognito" : "Tap to join") + .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor) + .font(.callout) + .padding(.trailing, 60) + .overlay(DetermineWidth()) + } + } else { + groupInvitationText() + .padding(.trailing, 60) + .overlay(DetermineWidth()) + } + } + .padding(.bottom, 2) + + if progressByTimeout { + ProgressView().scaleEffect(2) } } - .padding(.bottom, 2) + chatItem.timestampText .font(.caption) .foregroundColor(.secondary) @@ -55,11 +66,24 @@ struct CIGroupInvitationView: View { .cornerRadius(18) .textSelection(.disabled) .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } + .onChange(of: inProgress) { inProgress in + if inProgress { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } + } if action { v.onTapGesture { - joinGroup(groupInvitation.groupId) + inProgress = true + joinGroup(groupInvitation.groupId) { + await MainActor.run { inProgress = false } + } } + .disabled(inProgress) } else { v } @@ -67,7 +91,7 @@ struct CIGroupInvitationView: View { private func groupInfoView(_ action: Bool) -> some View { var color: Color - if action { + if action && !inProgress { color = chatIncognito ? .indigo : .accentColor } else { color = Color(uiColor: .tertiaryLabel) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 088335d198..971c0e0888 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -33,19 +33,32 @@ struct ChatListNavLink: View { @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false @State private var showDeleteContactActionSheet = false + @State private var inProgress = false + @State private var progressByTimeout = false var body: some View { - switch chat.chatInfo { - case let .direct(contact): - contactNavLink(contact) - case let .group(groupInfo): - groupNavLink(groupInfo) - case let .contactRequest(cReq): - contactRequestNavLink(cReq) - case let .contactConnection(cConn): - contactConnectionNavLink(cConn) - case let .invalidJSON(json): - invalidJSONPreview(json) + Group { + switch chat.chatInfo { + case let .direct(contact): + contactNavLink(contact) + case let .group(groupInfo): + groupNavLink(groupInfo) + case let .contactRequest(cReq): + contactRequestNavLink(cReq) + case let .contactConnection(cConn): + contactConnectionNavLink(cConn) + case let .invalidJSON(json): + invalidJSONPreview(json) + } + } + .onChange(of: inProgress) { inProgress in + if inProgress { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } } } @@ -53,7 +66,7 @@ struct ChatListNavLink: View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat) } + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } ) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() @@ -101,7 +114,7 @@ struct ChatListNavLink: View { @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { switch (groupInfo.membership.memberStatus) { case .memInvited: - ChatPreviewView(chat: chat) + ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) .frame(height: rowHeights[dynamicTypeSize]) .swipeActions(edge: .trailing, allowsFullSwipe: true) { joinGroupButton() @@ -112,12 +125,16 @@ struct ChatListNavLink: View { .onTapGesture { showJoinGroupDialog = true } .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") { - joinGroup(groupInfo.groupId) + inProgress = true + joinGroup(groupInfo.groupId) { + await MainActor.run { inProgress = false } + } } Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } } + .disabled(inProgress) case .memAccepted: - ChatPreviewView(chat: chat) + ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) .frame(height: rowHeights[dynamicTypeSize]) .onTapGesture { AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) @@ -134,7 +151,7 @@ struct ChatListNavLink: View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat) }, + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, disabled: !groupInfo.ready ) .frame(height: rowHeights[dynamicTypeSize]) @@ -159,7 +176,10 @@ struct ChatListNavLink: View { private func joinGroupButton() -> some View { Button { - joinGroup(chat.chatInfo.apiId) + inProgress = true + joinGroup(chat.chatInfo.apiId) { + await MainActor.run { inProgress = false } + } } label: { Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward") } @@ -419,7 +439,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, ) } -func joinGroup(_ groupId: Int64) { +func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { Task { logger.debug("joinGroup") do { @@ -434,7 +454,9 @@ func joinGroup(_ groupId: Int64) { AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.") await deleteGroup() } + await onComplete() } catch let error { + await onComplete() let a = getErrorAlert(error, "Error joining group") AlertManager.shared.showAlertMsg(title: a.title, message: a.message) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index b7b7e73dca..71f8baf748 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct ChatPreviewView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat + @Binding var progressByTimeout: Bool @Environment(\.colorScheme) var colorScheme var darkGreen = Color(red: 0, green: 0.5, blue: 0) @@ -252,6 +253,12 @@ struct ChatPreviewView: View { } else { incognitoIcon(chat.chatInfo.incognito) } + case .group: + if progressByTimeout { + ProgressView() + } else { + incognitoIcon(chat.chatInfo.incognito) + } default: incognitoIcon(chat.chatInfo.incognito) } @@ -280,30 +287,30 @@ struct ChatPreviewView_Previews: PreviewProvider { ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 78)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 40f2b32e58..94f9a6b54e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -269,8 +269,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: cancelFile = { fileId -> withApi { chatModel.controller.cancelFile(user, fileId) } }, - joinGroup = { groupId -> - withApi { chatModel.controller.apiJoinGroup(groupId) } + joinGroup = { groupId, onComplete -> + withApi { + chatModel.controller.apiJoinGroup(groupId) + onComplete.invoke() + } }, startCall = out@ { media -> withBGApi { @@ -431,7 +434,7 @@ fun ChatLayout( deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, startCall: (CallMediaType) -> Unit, endCall: () -> Unit, acceptCall: (Contact) -> Unit, @@ -720,7 +723,7 @@ fun BoxWithConstraintsScope.ChatItemsList( deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -872,7 +875,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { MemberImage(member) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) } } } else { @@ -881,7 +884,7 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) } } } @@ -891,7 +894,7 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) } } } else { // direct message @@ -1323,7 +1326,7 @@ fun PreviewChatLayout() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, startCall = {}, endCall = {}, acceptCall = { _ -> }, @@ -1393,7 +1396,7 @@ fun PreviewGroupChatLayout() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, startCall = {}, endCall = {}, acceptCall = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 6ee29b8304..56dd7a360a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource @@ -17,6 +18,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.res.MR +import kotlinx.coroutines.delay @Composable fun CIGroupInvitationView( @@ -24,16 +26,26 @@ fun CIGroupInvitationView( groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole, chatIncognito: Boolean = false, - joinGroup: (Long) -> Unit + joinGroup: (Long, () -> Unit) -> Unit ) { val sent = ci.chatDir.sent val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending + val inProgress = remember { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } @Composable fun groupInfoView() { val p = groupInvitation.groupProfile val iconColor = - if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary + if (action && !inProgress.value) if (chatIncognito) Indigo else MaterialTheme.colors.primary else if (isInDarkTheme()) FileDark else FileLight Row( @@ -70,8 +82,9 @@ fun CIGroupInvitationView( val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Surface( - modifier = if (action) Modifier.clickable(onClick = { - joinGroup(groupInvitation.groupId) + modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { + inProgress.value = true + joinGroup(groupInvitation.groupId) { inProgress.value = false } }) else Modifier, shape = RoundedCornerShape(18.dp), color = if (sent) sentColor else receivedColor, @@ -83,26 +96,45 @@ fun CIGroupInvitationView( .padding(start = 8.dp, end = 12.dp), contentAlignment = Alignment.BottomEnd ) { - Column( - Modifier - .defaultMinSize(minWidth = 220.dp) - .padding(bottom = 4.dp), + Box( + contentAlignment = Alignment.Center ) { - groupInfoView() - Column(Modifier.padding(top = 2.dp, start = 5.dp)) { - Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) - if (action) { - groupInvitationText() - Text(stringResource( - if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join), - color = if (chatIncognito) Indigo else MaterialTheme.colors.primary) - } else { - Box(Modifier.padding(end = 48.dp)) { + Column( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 4.dp), + ) { + groupInfoView() + Column(Modifier.padding(top = 2.dp, start = 5.dp)) { + Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) + if (action) { groupInvitationText() + Text( + stringResource( + if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join + ), + color = if (inProgress.value) + MaterialTheme.colors.secondary + else + if (chatIncognito) Indigo else MaterialTheme.colors.primary + ) + } else { + Box(Modifier.padding(end = 48.dp)) { + groupInvitationText() + } } } } + + if (progressByTimeout) { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isInDarkTheme()) FileDark else FileLight, + strokeWidth = 3.dp + ) + } } + Text( ci.timestampText, color = MaterialTheme.colors.secondary, @@ -124,7 +156,7 @@ fun PendingCIGroupInvitationViewPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } @@ -140,7 +172,7 @@ fun CIGroupInvitationViewAcceptedPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } @@ -156,7 +188,7 @@ fun CIGroupInvitationViewLongNamePreview() { status = CIGroupInvitationStatus.Accepted ), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index dd07a3fc19..dd9fe4d4a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -50,7 +50,7 @@ fun ChatItemView( deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, @@ -578,7 +578,7 @@ fun PreviewChatItemView() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, @@ -609,7 +609,7 @@ fun PreviewChatItemViewDeletedContent() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 2ff33ead57..bcabb7cfd4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -44,11 +45,22 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } } val showChatPreviews = chatModel.showChatPreviews.value + val inProgress = remember { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } + when (chat.chatInfo) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, @@ -58,9 +70,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) }, - click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, - dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, + chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, + click = { if (!inProgress.value) groupChatAction(chat.chatInfo.groupInfo, chatModel, inProgress) }, + dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, showMenu, stopped, selectedChat @@ -110,9 +122,9 @@ fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) { withBGApi { openChat(chatInfo, chatModel) } } -fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) { +fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel) + GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) } } @@ -193,10 +205,19 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { +fun GroupMenuItems( + chat: Chat, + groupInfo: GroupInfo, + chatModel: ChatModel, + showMenu: MutableState, + inProgress: MutableState, + showMarkRead: Boolean +) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> { - JoinGroupAction(chat, groupInfo, chatModel, showMenu) + if (!inProgress.value) { + JoinGroupAction(chat, groupInfo, chatModel, showMenu, inProgress) + } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) } @@ -317,8 +338,20 @@ fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, sh } @Composable -fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { - val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } } +fun JoinGroupAction( + chat: Chat, + groupInfo: GroupInfo, + chatModel: ChatModel, + showMenu: MutableState, + inProgress: MutableState +) { + val joinGroup: () -> Unit = { + withApi { + inProgress.value = true + chatModel.controller.apiJoinGroup(groupInfo.groupId) + inProgress.value = false + } + } ItemAction( if (chat.chatInfo.incognito) stringResource(MR.strings.join_group_incognito_button) else stringResource(MR.strings.join_group_button), if (chat.chatInfo.incognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_login), @@ -558,12 +591,18 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { ) } -fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) { +fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.join_group_question), text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members), confirmText = if (groupInfo.membership.memberIncognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }, + onConfirm = { + withApi { + inProgress?.value = true + chatModel.controller.apiJoinGroup(groupInfo.groupId) + inProgress?.value = false + } + }, dismissText = generalGetString(MR.strings.delete_verb), onDismiss = { deleteGroup(groupInfo, chatModel) } ) @@ -680,7 +719,9 @@ fun PreviewChatListNavLinkDirect() { null, null, stopped = false, - linkMode = SimplexLinkMode.DESCRIPTION + linkMode = SimplexLinkMode.DESCRIPTION, + inProgress = false, + progressByTimeout = false ) }, click = {}, @@ -721,7 +762,9 @@ fun PreviewChatListNavLinkGroup() { null, null, stopped = false, - linkMode = SimplexLinkMode.DESCRIPTION + linkMode = SimplexLinkMode.DESCRIPTION, + inProgress = false, + progressByTimeout = false ) }, click = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index a5775d369b..d3413e2e08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -37,7 +37,9 @@ fun ChatPreviewView( currentUserProfileDisplayName: String?, contactNetworkStatus: NetworkStatus?, stopped: Boolean, - linkMode: SimplexLinkMode + linkMode: SimplexLinkMode, + inProgress: Boolean, + progressByTimeout: Boolean ) { val cInfo = chat.chatInfo @@ -135,7 +137,12 @@ fun ChatPreviewView( } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary) + GroupMemberStatus.MemInvited -> chatPreviewTitleText( + if (inProgress) + MaterialTheme.colors.secondary + else + if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary + ) GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary) else -> chatPreviewTitleText() } @@ -194,6 +201,17 @@ fun ChatPreviewView( } } + @Composable + fun progressView() { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(15.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) + } + @Composable fun chatStatusImage() { if (cInfo is ChatInfo.Direct) { @@ -213,17 +231,17 @@ fun ChatPreviewView( ) else -> - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(15.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 1.5.dp - ) + progressView() } } else { IncognitoIcon(chat.chatInfo.incognito) } + } else if (cInfo is ChatInfo.Group) { + if (progressByTimeout) { + progressView() + } else { + IncognitoIcon(chat.chatInfo.incognito) + } } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -351,6 +369,6 @@ fun unreadCountStr(n: Int): String { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) + ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false) } } From 16bda260225d3ee1715b23cf17cda56adb5f4c37 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 26 Oct 2023 15:44:50 +0100 Subject: [PATCH 121/294] core: derive JSON with TH (#3275) * core: derive JSON with TH * fix tests * simplify events * reduce diff * fix * update simplexmq * update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 1 + src/Simplex/Chat/Call.hs | 135 ++---- src/Simplex/Chat/Controller.hs | 286 +++++------ src/Simplex/Chat/Markdown.hs | 42 +- src/Simplex/Chat/Messages.hs | 445 ++++++++---------- src/Simplex/Chat/Messages/CIContent.hs | 184 +------- src/Simplex/Chat/Messages/CIContent/Events.hs | 116 +++++ src/Simplex/Chat/Mobile.hs | 40 +- src/Simplex/Chat/Mobile/File.hs | 13 +- src/Simplex/Chat/Protocol.hs | 120 ++--- src/Simplex/Chat/Remote/Protocol.hs | 1 - src/Simplex/Chat/Remote/Types.hs | 11 +- src/Simplex/Chat/Store/Profiles.hs | 17 +- src/Simplex/Chat/Store/Shared.hs | 15 +- src/Simplex/Chat/Types.hs | 315 +++++-------- src/Simplex/Chat/Types/Preferences.hs | 202 ++++---- src/Simplex/Chat/Types/Util.hs | 3 - src/Simplex/Chat/View.hs | 22 +- stack.yaml | 2 +- tests/MobileTests.hs | 10 +- 23 files changed, 849 insertions(+), 1136 deletions(-) create mode 100644 src/Simplex/Chat/Messages/CIContent/Events.hs diff --git a/cabal.project b/cabal.project index 5c19fcd446..9cc0a7be6a 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: d920a2504b6d4653748da7d297cb13cd0a0f1f48 + tag: 511d793b927b1e2f12999e0829718671b3a8f0cb source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26188aa774..658da37f6b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."d920a2504b6d4653748da7d297cb13cd0a0f1f48" = "0r53wn01z044h6myvd458n3hiqsz64kpv59khgybzwdw5mmqnp34"; + "https://github.com/simplex-chat/simplexmq.git"."511d793b927b1e2f12999e0829718671b3a8f0cb" = "14zk7g33x4a1g5d1dihaklvwzll86ks6fk87kf6l6l5back581zi"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f061f8ac86..e9036ea604 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -36,6 +36,7 @@ library Simplex.Chat.Markdown Simplex.Chat.Messages Simplex.Chat.Messages.CIContent + Simplex.Chat.Messages.CIContent.Events Simplex.Chat.Migrations.M20220101_initial Simplex.Chat.Migrations.M20220122_v1_1 Simplex.Chat.Migrations.M20220205_chat_item_status diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2ef5c4356f..e8049dcbb5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -58,6 +58,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 7e6e60c8f5..313442838e 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -1,18 +1,18 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} module Simplex.Chat.Call where -import Data.Aeson (FromJSON, ToJSON) -import qualified Data.Aeson as J +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) @@ -20,12 +20,11 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Types (Contact, ContactId, User) import Simplex.Chat.Types.Util (decodeJSON, encodeJSON) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) data Call = Call { contactId :: ContactId, @@ -47,14 +46,7 @@ data CallStateTag | CSTCallOfferSent | CSTCallOfferReceived | CSTCallNegotiated - deriving (Show, Generic) - -instance FromJSON CallStateTag where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CSTCall" - -instance ToJSON CallStateTag where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CSTCall" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CSTCall" + deriving (Show) callStateTag :: CallState -> CallStateTag callStateTag = \case @@ -93,21 +85,7 @@ data CallState peerCallSession :: WebRTCSession, sharedKey :: Maybe C.Key } - deriving (Show, Generic) - --- database representation -instance FromJSON CallState where - parseJSON = J.genericParseJSON $ singleFieldJSON fstToLower - -instance ToJSON CallState where - toJSON = J.genericToJSON $ singleFieldJSON fstToLower - toEncoding = J.genericToEncoding $ singleFieldJSON fstToLower - -instance ToField CallState where - toField = toField . encodeJSON - -instance FromField CallState where - fromField = fromTextField_ decodeJSON + deriving (Show) newtype CallId = CallId ByteString deriving (Eq, Show) @@ -135,17 +113,13 @@ data RcvCallInvitation = RcvCallInvitation sharedKey :: Maybe C.Key, callTs :: UTCTime } - deriving (Show, Generic, FromJSON) - -instance ToJSON RcvCallInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data CallType = CallType { media :: CallMedia, capabilities :: CallCapabilities } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) defaultCallType :: CallType defaultCallType = CallType CMVideo $ CallCapabilities {encryption = True} @@ -153,95 +127,54 @@ defaultCallType = CallType CMVideo $ CallCapabilities {encryption = True} encryptedCall :: CallType -> Bool encryptedCall CallType {capabilities = CallCapabilities {encryption}} = encryption -instance ToJSON CallType where toEncoding = J.genericToEncoding J.defaultOptions - -- | * Types for chat protocol data CallInvitation = CallInvitation { callType :: CallType, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data CallMedia = CMAudio | CMVideo - deriving (Eq, Show, Generic) - -instance FromJSON CallMedia where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CM" - -instance ToJSON CallMedia where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CM" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CM" + deriving (Eq, Show) data CallCapabilities = CallCapabilities { encryption :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallCapabilities where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CallOffer = CallOffer { callType :: CallType, rtcSession :: WebRTCSession, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallOffer where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data WebRTCCallOffer = WebRTCCallOffer { callType :: CallType, rtcSession :: WebRTCSession } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON WebRTCCallOffer where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data CallAnswer = CallAnswer { rtcSession :: WebRTCSession } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallAnswer where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CallExtraInfo = CallExtraInfo { rtcExtraInfo :: WebRTCExtraInfo } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallExtraInfo where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data WebRTCSession = WebRTCSession { rtcSession :: Text, -- LZW compressed JSON encoding of offer or answer rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON WebRTCSession where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data WebRTCExtraInfo = WebRTCExtraInfo { rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON WebRTCExtraInfo where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data WebRTCCallStatus = WCSConnecting | WCSConnected | WCSDisconnected | WCSFailed deriving (Show) @@ -259,3 +192,37 @@ instance StrEncoding WebRTCCallStatus where "disconnected" -> pure WCSDisconnected "failed" -> pure WCSFailed _ -> fail "bad WebRTCCallStatus" + +$(J.deriveJSON (enumJSON $ dropPrefix "CSTCall") ''CallStateTag) + +$(J.deriveJSON (enumJSON $ dropPrefix "CM") ''CallMedia) + +$(J.deriveJSON defaultJSON ''CallCapabilities) + +$(J.deriveJSON defaultJSON ''CallType) + +$(J.deriveJSON defaultJSON ''CallInvitation) + +$(J.deriveJSON defaultJSON ''WebRTCSession) + +$(J.deriveJSON defaultJSON ''CallOffer) + +$(J.deriveJSON defaultJSON ''WebRTCCallOffer) + +$(J.deriveJSON defaultJSON ''CallAnswer) + +$(J.deriveJSON defaultJSON ''WebRTCExtraInfo) + +$(J.deriveJSON defaultJSON ''CallExtraInfo) + +-- database representation +$(J.deriveJSON (singleFieldJSON fstToLower) ''CallState) + +instance ToField CallState where + toField = toField . encodeJSON + +instance FromField CallState where + fromField = fromTextField_ decodeJSON + +$(J.deriveJSON defaultJSON ''RcvCallInvitation) + diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1bb28f89d2..66ab513a0d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -2,7 +2,6 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} @@ -41,7 +40,6 @@ import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) -import GHC.Generics (Generic) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -67,7 +65,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) @@ -196,14 +194,7 @@ data ChatController = ChatController } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase - deriving (Show, Generic) - -instance FromJSON HelpSection where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "HS" - -instance ToJSON HelpSection where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "HS" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" + deriving (Show) data ChatCommand = ShowActiveUser @@ -698,28 +689,14 @@ data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} | CPGroupLink {groupLinkPlan :: GroupLinkPlan} - deriving (Show, Generic) - -instance FromJSON ConnectionPlan where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CP" - -instance ToJSON ConnectionPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CP" + deriving (Show) data InvitationLinkPlan = ILPOk | ILPOwnLink | ILPConnecting {contact_ :: Maybe Contact} | ILPKnown {contact :: Contact} - deriving (Show, Generic) - -instance FromJSON InvitationLinkPlan where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "ILP" - -instance ToJSON InvitationLinkPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "ILP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "ILP" + deriving (Show) data ContactAddressPlan = CAPOk @@ -727,14 +704,7 @@ data ContactAddressPlan | CAPConnectingConfirmReconnect | CAPConnectingProhibit {contact :: Contact} | CAPKnown {contact :: Contact} - deriving (Show, Generic) - -instance FromJSON ContactAddressPlan where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CAP" - -instance ToJSON ContactAddressPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CAP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CAP" + deriving (Show) data GroupLinkPlan = GLPOk @@ -742,14 +712,7 @@ data GroupLinkPlan | GLPConnectingConfirmReconnect | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} | GLPKnown {groupInfo :: GroupInfo} - deriving (Show, Generic) - -instance FromJSON GroupLinkPlan where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "GLP" - -instance ToJSON GroupLinkPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "GLP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "GLP" + deriving (Show) connectionPlanProceed :: ConnectionPlan -> Bool connectionPlanProceed = \case @@ -794,7 +757,7 @@ instance ToJSON AgentQueueId where toEncoding = strToJEncoding data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]} - deriving (Show, Generic, FromJSON) + deriving (Show) data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p) @@ -805,36 +768,17 @@ data UserProtoServers p = UserProtoServers protoServers :: NonEmpty (ServerCfg p), presetServers :: NonEmpty (ProtoServerWithAuth p) } - deriving (Show, Generic) - -instance ProtocolTypeI p => FromJSON (UserProtoServers p) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance ProtocolTypeI p => ToJSON (UserProtoServers p) where - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p) -instance FromJSON AUserProtoServers where - parseJSON v = J.withObject "AUserProtoServers" parse v - where - parse o = do - AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" - case userProtocol p of - Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v - Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p - -instance ToJSON AUserProtoServers where - toJSON (AUPS s) = J.genericToJSON J.defaultOptions s - toEncoding (AUPS s) = J.genericToEncoding J.defaultOptions s - deriving instance Show AUserProtoServers data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} - deriving (Show, Generic, FromJSON) + deriving (Show) data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey} - deriving (Show, Generic, FromJSON) + deriving (Show) newtype DBEncryptionKey = DBEncryptionKey String deriving (Show) @@ -852,41 +796,25 @@ data ContactSubStatus = ContactSubStatus { contact :: Contact, contactError :: Maybe ChatError } - deriving (Show, Generic, FromJSON) - -instance ToJSON ContactSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data MemberSubStatus = MemberSubStatus { member :: GroupMember, memberError :: Maybe ChatError } - deriving (Show, Generic, FromJSON) - -instance ToJSON MemberSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data UserContactSubStatus = UserContactSubStatus { userContact :: UserContact, userContactError :: Maybe ChatError } - deriving (Show, Generic, FromJSON) - -instance ToJSON UserContactSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data PendingSubStatus = PendingSubStatus { connection :: PendingContactConnection, connError :: Maybe ChatError } - deriving (Show, Generic, FromJSON) - -instance ToJSON PendingSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data UserProfileUpdateSummary = UserProfileUpdateSummary { notChanged :: Int, @@ -894,16 +822,14 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary updateFailures :: Int, changedContacts :: [Contact] } - deriving (Show, Generic, FromJSON) - -instance ToJSON UserProfileUpdateSummary where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ComposedMessage = ComposedMessage { fileSource :: Maybe CryptoFile, quotedItemId :: Maybe ChatItemId, msgContent :: MsgContent } - deriving (Show, Generic) + deriving (Show) -- This instance is needed for backward compatibility, can be removed in v6.0 instance FromJSON ComposedMessage where @@ -918,24 +844,16 @@ instance FromJSON ComposedMessage where parseJSON invalid = JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) -instance ToJSON ComposedMessage where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - data XFTPFileConfig = XFTPFileConfig { minFileSize :: Integer } - deriving (Show, Generic, FromJSON) + deriving (Show) defaultXFTPFileConfig :: XFTPFileConfig defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} -instance ToJSON XFTPFileConfig where toEncoding = J.genericToEncoding J.defaultOptions - data NtfMsgInfo = NtfMsgInfo {msgTs :: UTCTime, msgFlags :: MsgFlags} - deriving (Show, Generic, FromJSON) - -instance ToJSON NtfMsgInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode} @@ -945,25 +863,19 @@ data SwitchProgress = SwitchProgress switchPhase :: SwitchPhase, connectionStats :: ConnectionStats } - deriving (Show, Generic, FromJSON) - -instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data RatchetSyncProgress = RatchetSyncProgress { ratchetSyncStatus :: RatchetSyncState, connectionStats :: ConnectionStats } - deriving (Show, Generic, FromJSON) - -instance ToJSON RatchetSyncProgress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ParsedServerAddress = ParsedServerAddress { serverAddress :: Maybe ServerAddress, parseError :: String } - deriving (Show, Generic, FromJSON) - -instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ServerAddress = ServerAddress { serverProtocol :: AProtocolType, @@ -972,9 +884,7 @@ data ServerAddress = ServerAddress keyHash :: String, basicAuth :: String } - deriving (Show, Generic, FromJSON) - -instance ToJSON ServerAddress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data TimedMessagesEnabled = TMEEnableSetTTL Int @@ -996,22 +906,18 @@ data CoreVersionInfo = CoreVersionInfo simplexmqVersion :: String, simplexmqCommit :: String } - deriving (Show, Generic, FromJSON) - -instance ToJSON CoreVersionInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data SendFileMode = SendFileSMP (Maybe InlineFileMode) | SendFileXFTP - deriving (Show, Generic) + deriving (Show) data SlowSQLQuery = SlowSQLQuery { query :: Text, queryStats :: SlowQueryStats } - deriving (Show, Generic, FromJSON) - -instance ToJSON SlowSQLQuery where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ChatError = ChatError {errorType :: ChatErrorType} @@ -1020,14 +926,7 @@ data ChatError | ChatErrorDatabase {databaseError :: DatabaseError} | ChatErrorRemoteCtrl {remoteCtrlError :: RemoteCtrlError} | ChatErrorRemoteHost {remoteHostId :: RemoteHostId, remoteHostError :: RemoteHostError} - deriving (Show, Exception, Generic) - -instance FromJSON ChatError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "Chat" - -instance ToJSON ChatError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "Chat" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat" + deriving (Show, Exception) data ChatErrorType = CENoActiveUser @@ -1107,14 +1006,7 @@ data ChatErrorType | CEPeerChatVRangeIncompatible | CEInternalError {message :: String} | CEException {message :: String} - deriving (Show, Exception, Generic) - -instance FromJSON ChatErrorType where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CE" - -instance ToJSON ChatErrorType where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE" + deriving (Show, Exception) data DatabaseError = DBErrorEncrypted @@ -1122,24 +1014,10 @@ data DatabaseError | DBErrorNoFile {dbFile :: String} | DBErrorExport {sqliteError :: SQLiteError} | DBErrorOpen {sqliteError :: SQLiteError} - deriving (Show, Exception, Generic) - -instance FromJSON DatabaseError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "DB" - -instance ToJSON DatabaseError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DB" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DB" + deriving (Show, Exception) data SQLiteError = SQLiteErrorNotADatabase | SQLiteError String - deriving (Show, Exception, Generic) - -instance FromJSON SQLiteError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SQLite" - -instance ToJSON SQLiteError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SQLite" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SQLite" + deriving (Show, Exception) throwDBError :: ChatMonad m => DatabaseError -> m () throwDBError = throwError . ChatErrorDatabase @@ -1153,14 +1031,7 @@ data RemoteHostError | RHDisconnected {reason :: Text} -- ^ A session disconnected by a host | RHConnectionLost {reason :: Text} -- ^ A session disconnected due to transport issues | RHProtocolError RemoteProtocolError - deriving (Show, Exception, Generic) - -instance FromJSON RemoteHostError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RH" - -instance ToJSON RemoteHostError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RH" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RH" + deriving (Show, Exception) -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteCtrlError @@ -1176,26 +1047,12 @@ data RemoteCtrlError | RCEHTTP2RespStatus {statusCode :: Maybe Int} -- TODO remove | RCEInvalidResponse {responseError :: String} | RCEProtocolError {protocolError :: RemoteProtocolError} - deriving (Show, Exception, Generic) - -instance FromJSON RemoteCtrlError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RCE" - -instance ToJSON RemoteCtrlError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RCE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RCE" + deriving (Show, Exception) data ArchiveError = AEImport {chatError :: ChatError} | AEImportFile {file :: String, chatError :: ChatError} - deriving (Show, Exception, Generic) - -instance FromJSON ArchiveError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "AE" - -instance ToJSON ArchiveError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" + deriving (Show, Exception) data RemoteCtrlSession = RemoteCtrlSession { -- | Host (mobile) side of transport to process remote commands and forward notifications @@ -1295,4 +1152,83 @@ withStoreCtx ctx_ action = do handleInternal :: String -> SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr +$(JQ.deriveJSON (enumJSON $ dropPrefix "HS") ''HelpSection) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ILP") ''InvitationLinkPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RH") ''RemoteHostError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCE") ''RemoteCtrlError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SQLite") ''SQLiteError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "DB") ''DatabaseError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Chat") ''ChatError) + +$(JQ.deriveJSON defaultJSON ''ContactSubStatus) + +$(JQ.deriveJSON defaultJSON ''MemberSubStatus) + +$(JQ.deriveJSON defaultJSON ''UserContactSubStatus) + +$(JQ.deriveJSON defaultJSON ''PendingSubStatus) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "AE") ''ArchiveError) + +$(JQ.deriveJSON defaultJSON ''UserProfileUpdateSummary) + +$(JQ.deriveJSON defaultJSON ''NtfMsgInfo) + +$(JQ.deriveJSON defaultJSON ''SwitchProgress) + +$(JQ.deriveJSON defaultJSON ''RatchetSyncProgress) + +$(JQ.deriveJSON defaultJSON ''ServerAddress) + +$(JQ.deriveJSON defaultJSON ''ParsedServerAddress) + +$(JQ.deriveJSON defaultJSON ''CoreVersionInfo) + +$(JQ.deriveJSON defaultJSON ''SlowSQLQuery) + +instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) + +instance ProtocolTypeI p => FromJSON (UserProtoServers p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) + +instance ProtocolTypeI p => ToJSON (UserProtoServers p) where + toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) + toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) + +instance FromJSON AUserProtoServers where + parseJSON v = J.withObject "AUserProtoServers" parse v + where + parse o = do + AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" + case userProtocol p of + Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v + Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p + +instance ToJSON AUserProtoServers where + toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s + toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) + +$(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) + +$(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) + +$(JQ.deriveJSON defaultJSON ''XFTPFileConfig) + +$(JQ.deriveToJSON defaultJSON ''ComposedMessage) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 793fa753e1..391f43fa37 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -1,9 +1,9 @@ {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} @@ -13,6 +13,7 @@ module Simplex.Chat.Markdown where import Control.Applicative (optional, (<|>)) import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A import Data.Char (isDigit) @@ -27,12 +28,11 @@ import Data.String import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import GHC.Generics import Simplex.Chat.Types import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..)) import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types @@ -52,17 +52,10 @@ data Format | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} | Email | Phone - deriving (Eq, Show, Generic) + deriving (Eq, Show) data SimplexLinkType = XLContact | XLInvitation | XLGroup - deriving (Eq, Show, Generic) - -instance FromJSON SimplexLinkType where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "XL" - -instance ToJSON SimplexLinkType where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "XL" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "XL" + deriving (Eq, Show) colored :: Color -> Format colored = Colored . FormatColor @@ -70,13 +63,6 @@ colored = Colored . FormatColor markdown :: Format -> Text -> Markdown markdown = Markdown . Just -instance FromJSON Format where - parseJSON = J.genericParseJSON $ sumTypeJSON fstToLower - -instance ToJSON Format where - toJSON = J.genericToJSON $ sumTypeJSON fstToLower - toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower - instance Semigroup Markdown where m <> (Markdown _ "") = m (Markdown _ "") <> m = m @@ -122,10 +108,7 @@ instance ToJSON FormatColor where White -> "white" data FormattedText = FormattedText {format :: Maybe Format, text :: Text} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FormattedText where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) instance IsString FormattedText where fromString = FormattedText Nothing . T.pack @@ -133,11 +116,6 @@ instance IsString FormattedText where type MarkdownList = [FormattedText] data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList} - deriving (Generic) - -instance ToJSON ParsedMarkdown where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} unmarked :: Text -> Markdown unmarked = Markdown Nothing @@ -257,3 +235,11 @@ markdownP = mconcat <$> A.many' fragmentP linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of Just (CRDataGroup _) -> XLGroup Nothing -> XLContact + +$(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) + +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''Format) + +$(JQ.deriveJSON defaultJSON ''FormattedText) + +$(JQ.deriveToJSON defaultJSON ''ParsedMarkdown) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 8bc302f5d9..2718b088ba 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} @@ -10,6 +9,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -20,6 +20,7 @@ import Control.Applicative ((<|>)) import Data.Aeson (FromJSON, ToJSON, (.:)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE +import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB @@ -33,7 +34,6 @@ import Data.Type.Equality import Data.Typeable (Typeable) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Markdown import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol @@ -43,17 +43,15 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptSta import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, parseAll, enumJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection - deriving (Eq, Show, Ord, Generic) + deriving (Eq, Show, Ord) data ChatName = ChatName {chatType :: ChatType, chatName :: Text} - deriving (Show, Generic, FromJSON) - -instance ToJSON ChatName where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) chatTypeStr :: ChatType -> String chatTypeStr = \case @@ -68,13 +66,6 @@ chatNameStr (ChatName cType name) = chatTypeStr cType <> T.unpack name data ChatRef = ChatRef ChatType Int64 deriving (Eq, Show, Ord) -instance FromJSON ChatType where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CT" - -instance ToJSON ChatType where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CT" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CT" - data ChatInfo (c :: ChatType) where DirectChat :: Contact -> ChatInfo 'CTDirect GroupChat :: GroupInfo -> ChatInfo 'CTGroup @@ -113,14 +104,8 @@ data JSONChatInfo | JCInfoGroup {groupInfo :: GroupInfo} | JCInfoContactRequest {contactRequest :: UserContactRequest} | JCInfoContactConnection {contactConnection :: PendingContactConnection} - deriving (Generic) -instance FromJSON JSONChatInfo where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCInfo" - -instance ToJSON JSONChatInfo where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCInfo" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCInfo" +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCInfo") ''JSONChatInfo) instance ChatTypeI c => FromJSON (ChatInfo c) where parseJSON v = (\(AChatInfo _ c) -> checkChatType c) <$?> J.parseJSON v @@ -163,14 +148,7 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem reactions :: [CIReactionCount], file :: Maybe (CIFile d) } - deriving (Show, Generic) - -instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance (ChatTypeI c, MsgDirectionI d) => ToJSON (ChatItem c d) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) isMention :: ChatItem c d -> Bool isMention ChatItem {chatDir, quotedItem} = case chatDir of @@ -195,34 +173,14 @@ deriving instance Show (CIDirection c d) data CCIDirection c = forall d. MsgDirectionI d => CCID (SMsgDirection d) (CIDirection c d) -instance ChatTypeI c => FromJSON (CCIDirection c) where - parseJSON v = (\(ACID _ d x) -> checkChatType (CCID d x)) <$?> J.parseJSON v - data ACIDirection = forall c d. (ChatTypeI c, MsgDirectionI d) => ACID (SChatType c) (SMsgDirection d) (CIDirection c d) -instance FromJSON ACIDirection where - parseJSON v = jsonACIDirection <$> J.parseJSON v - data JSONCIDirection = JCIDirectSnd | JCIDirectRcv | JCIGroupSnd | JCIGroupRcv {groupMember :: GroupMember} - deriving (Generic, Show) - -instance FromJSON JSONCIDirection where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" - -instance ToJSON JSONCIDirection where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" - -instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where - parseJSON v = (\(CCID _ x') -> checkDirection x') <$?> J.parseJSON v - -instance ToJSON (CIDirection c d) where - toJSON = J.toJSON . jsonCIDirection - toEncoding = J.toEncoding . jsonCIDirection + deriving (Show) jsonCIDirection :: CIDirection c d -> JSONCIDirection jsonCIDirection = \case @@ -239,26 +197,12 @@ jsonACIDirection = \case JCIGroupRcv m -> ACID SCTGroup SMDRcv $ CIGroupRcv m data CIReactionCount = CIReactionCount {reaction :: MsgReaction, userReacted :: Bool, totalReacted :: Int} - deriving (Show, Generic, FromJSON) - -instance ToJSON CIReactionCount where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (ChatItem c d) deriving instance Show (CChatItem c) -instance forall c. ChatTypeI c => FromJSON (CChatItem c) where - parseJSON v = J.withObject "CChatItem" parse v - where - parse o = do - CCID d (_ :: CIDirection c d) <- o .: "chatDir" - ci <- J.parseJSON @(ChatItem c d) v - pure $ CChatItem d ci - -instance ChatTypeI c => ToJSON (CChatItem c) where - toJSON (CChatItem _ ci) = J.toJSON ci - toEncoding (CChatItem _ ci) = J.toEncoding ci - cchatItemId :: CChatItem c -> ChatItemId cchatItemId (CChatItem _ ci) = chatItemId' ci @@ -325,51 +269,25 @@ data Chat c = Chat chatItems :: [CChatItem c], chatStats :: ChatStats } - deriving (Show, Generic) - -instance ChatTypeI c => ToJSON (Chat c) where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AChat = forall c. ChatTypeI c => AChat (SChatType c) (Chat c) deriving instance Show AChat -instance FromJSON AChat where - parseJSON = J.withObject "AChat" $ \o -> do - AChatInfo c chatInfo <- o .: "chatInfo" - chatItems <- o .: "chatItems" - chatStats <- o .: "chatStats" - pure $ AChat c Chat {chatInfo, chatItems, chatStats} - -instance ToJSON AChat where - toJSON (AChat _ c) = J.toJSON c - toEncoding (AChat _ c) = J.toEncoding c - data ChatStats = ChatStats { unreadCount :: Int, minUnreadItemId :: ChatItemId, unreadChat :: Bool } - deriving (Show, Generic, FromJSON) - -instance ToJSON ChatStats where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) -- | type to show a mix of messages from multiple chats data AChatItem = forall c d. (ChatTypeI c, MsgDirectionI d) => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) deriving instance Show AChatItem -instance FromJSON AChatItem where - parseJSON = J.withObject "AChatItem" $ \o -> do - AChatInfo c chatInfo <- o .: "chatInfo" - CChatItem d chatItem <- o .: "chatItem" - pure $ AChatItem c d chatInfo chatItem - -instance ToJSON AChatItem where - toJSON (AChatItem _ _ chat item) = J.toJSON $ JSONAnyChatItem chat item - toEncoding (AChatItem _ _ chat item) = J.toEncoding $ JSONAnyChatItem chat item - data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} - deriving (Generic) aChatItems :: AChat -> [AChatItem] aChatItems (AChat ct Chat {chatInfo, chatItems}) = map aChatItem chatItems @@ -387,10 +305,6 @@ updateFileStatus ci@ChatItem {file} status = case file of Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}} Nothing -> ci -instance (ChatTypeI c, MsgDirectionI d) => ToJSON (JSONAnyChatItem c d) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions - -- This type is not saved to DB, so all JSON encodings are platform-specific data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta { itemId :: ChatItemId, @@ -406,7 +320,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Show, Generic, FromJSON) + deriving (Show) mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta c d mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs createdAt updatedAt = @@ -415,15 +329,11 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item _ -> False in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, createdAt, updatedAt} -instance ChatTypeI c => ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions - data CITimed = CITimed { ttl :: Int, -- seconds deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read } - deriving (Show, Generic, FromJSON) - -instance ToJSON CITimed where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) ttl' :: CITimed -> Int ttl' CITimed {ttl} = ttl @@ -457,14 +367,7 @@ data CIQuote (c :: ChatType) = CIQuote content :: MsgContent, formattedText :: Maybe MarkdownList } - deriving (Show, Generic) - -instance ChatTypeI c => FromJSON (CIQuote c) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance ToJSON (CIQuote c) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction { chatDir :: CIDirection c d, @@ -472,41 +375,15 @@ data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction sentAt :: UTCTime, reaction :: MsgReaction } - deriving (Show, Generic) - -instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIReaction c d) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance ChatTypeI c => ToJSON (CIReaction c d) where - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AnyCIReaction = forall c d. ChatTypeI c => ACIR (SChatType c) (SMsgDirection d) (CIReaction c d) -instance FromJSON AnyCIReaction where - parseJSON v = J.withObject "AnyCIReaction" parse v - where - parse o = do - ACID c d (_ :: CIDirection c d) <- o .: "chatDir" - ACIR c d <$> J.parseJSON @(CIReaction c d) v - data ACIReaction = forall c d. ChatTypeI c => ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d) deriving instance Show ACIReaction -instance FromJSON ACIReaction where - parseJSON = J.withObject "ACIReaction" $ \o -> do - ACIR c d reaction <- o .: "chatReaction" - cInfo <- o .: "chatInfo" - pure $ ACIReaction c d cInfo reaction - -instance ToJSON ACIReaction where - toJSON (ACIReaction _ _ cInfo reaction) = J.toJSON $ JSONCIReaction cInfo reaction - toEncoding (ACIReaction _ _ cInfo reaction) = J.toEncoding $ JSONCIReaction cInfo reaction - data JSONCIReaction c d = JSONCIReaction {chatInfo :: ChatInfo c, chatReaction :: CIReaction c d} - deriving (Generic) - -instance ChatTypeI c => ToJSON (JSONCIReaction c d) where toEncoding = J.genericToEncoding J.defaultOptions data CIQDirection (c :: ChatType) where CIQDirectSnd :: CIQDirection 'CTDirect @@ -518,13 +395,6 @@ deriving instance Show (CIQDirection c) data ACIQDirection = forall c. ChatTypeI c => ACIQDirection (SChatType c) (CIQDirection c) -instance ChatTypeI c => FromJSON (CIQDirection c) where - parseJSON v = (\(ACIQDirection _ x) -> checkChatType x) . jsonACIQDirection <$?> J.parseJSON v - -instance ToJSON (CIQDirection c) where - toJSON = J.toJSON . jsonCIQDirection - toEncoding = J.toEncoding . jsonCIQDirection - jsonCIQDirection :: CIQDirection c -> Maybe JSONCIDirection jsonCIQDirection = \case CIQDirectSnd -> Just JCIDirectSnd @@ -556,14 +426,7 @@ data CIFile (d :: MsgDirection) = CIFile fileStatus :: CIFileStatus d, fileProtocol :: FileProtocol } - deriving (Show, Generic) - -instance MsgDirectionI d => FromJSON (CIFile d) where - parseJSON = J.genericParseJSON J.defaultOptions - -instance MsgDirectionI d => ToJSON (CIFile d) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data FileProtocol = FPSMP | FPXFTP deriving (Eq, Show, Ord) @@ -621,17 +484,6 @@ ciFileEnded = \case CIFSRcvError -> True CIFSInvalid {} -> True -instance MsgDirectionI d => FromJSON (CIFileStatus d) where - parseJSON v = (\(AFS _ s) -> checkDirection s) . aciFileStatusJSON <$?> J.parseJSON v - -instance ToJSON (CIFileStatus d) where - toJSON = J.toJSON . jsonCIFileStatus - toEncoding = J.toEncoding . jsonCIFileStatus - -instance MsgDirectionI d => ToField (CIFileStatus d) where toField = toField . decodeLatin1 . strEncode - -instance FromField ACIFileStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 - data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d) deriving instance Show ACIFileStatus @@ -689,14 +541,6 @@ data JSONCIFileStatus | JCIFSRcvCancelled | JCIFSRcvError | JCIFSInvalid {text :: Text} - deriving (Generic) - -instance FromJSON JSONCIFileStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCIFS" - -instance ToJSON JSONCIFileStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIFS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIFS" jsonCIFileStatus :: CIFileStatus d -> JSONCIFileStatus jsonCIFileStatus = \case @@ -758,19 +602,6 @@ deriving instance Eq (CIStatus d) deriving instance Show (CIStatus d) -instance MsgDirectionI d => FromJSON (CIStatus d) where - parseJSON v = (\(ACIStatus _ s) -> checkDirection s) . jsonACIStatus <$?> J.parseJSON v - -instance ToJSON (CIStatus d) where - toJSON = J.toJSON . jsonCIStatus - toEncoding = J.toEncoding . jsonCIStatus - -instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode - -instance (Typeable d, MsgDirectionI d) => FromField (CIStatus d) where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 - -instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 - data ACIStatus = forall d. MsgDirectionI d => ACIStatus (SMsgDirection d) (CIStatus d) deriving instance Show ACIStatus @@ -813,14 +644,7 @@ data JSONCIStatus | JCISRcvNew | JCISRcvRead | JCISInvalid {text :: Text} - deriving (Show, Generic) - -instance FromJSON JSONCIStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCIS" - -instance ToJSON JSONCIStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIS" + deriving (Show) jsonCIStatus :: CIStatus d -> JSONCIStatus jsonCIStatus = \case @@ -872,14 +696,7 @@ membersGroupItemStatus memStatusCounts data SndCIStatusProgress = SSPPartial | SSPComplete - deriving (Eq, Show, Generic) - -instance FromJSON SndCIStatusProgress where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "SSP" - -instance ToJSON SndCIStatusProgress where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "SSP" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "SSP" + deriving (Eq, Show) instance StrEncoding SndCIStatusProgress where strEncode = \case @@ -929,13 +746,6 @@ instance ChatTypeI 'CTContactRequest where chatTypeI = SCTContactRequest instance ChatTypeI 'CTContactConnection where chatTypeI = SCTContactConnection -instance ChatTypeI c => FromJSON (SChatType c) where - parseJSON v = (\(ACT t) -> checkChatType t) . aChatType <$?> J.parseJSON v - -instance ToJSON (SChatType c) where - toJSON = J.toJSON . toChatType - toEncoding = J.toEncoding . toChatType - toChatType :: SChatType c -> ChatType toChatType = \case SCTDirect -> CTDirect @@ -1007,9 +817,7 @@ data MsgMetaJSON = MsgMetaJSON serverTs :: UTCTime, sndId :: Int64 } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MsgMetaJSON where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) msgMetaToJson :: MsgMeta -> MsgMetaJSON msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId, serverTs), sndMsgId = sndId} = @@ -1022,9 +830,6 @@ msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId sndId } -msgMetaJson :: MsgMeta -> Text -msgMetaJson = decodeLatin1 . LB.toStrict . J.encode . msgMetaToJson - data MsgDeliveryStatus (d :: MsgDirection) where MDSRcvAgent :: MsgDeliveryStatus 'MDRcv MDSRcvAcknowledged :: MsgDeliveryStatus 'MDRcv @@ -1081,25 +886,11 @@ deriving instance Show (CIDeleted c) data ACIDeleted = forall c. ChatTypeI c => ACIDeleted (SChatType c) (CIDeleted c) -instance ChatTypeI c => FromJSON (CIDeleted c) where - parseJSON v = (\(ACIDeleted _ x) -> checkChatType x) . jsonACIDeleted <$?> J.parseJSON v - -instance ChatTypeI c => ToJSON (CIDeleted c) where - toJSON = J.toJSON . jsonCIDeleted - toEncoding = J.toEncoding . jsonCIDeleted - data JSONCIDeleted = JCIDDeleted {deletedTs :: Maybe UTCTime, chatType :: ChatType} | JCIDBlocked {deletedTs :: Maybe UTCTime} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} - deriving (Show, Generic) - -instance FromJSON JSONCIDeleted where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCID" - -instance ToJSON JSONCIDeleted where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCID" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCID" + deriving (Show) jsonCIDeleted :: forall d. ChatTypeI d => CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case @@ -1123,9 +914,7 @@ data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], memberDeliveryStatuses :: Maybe [MemberDeliveryStatus] } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ChatItemInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data ChatItemVersion = ChatItemVersion { chatItemVersionId :: Int64, @@ -1134,9 +923,7 @@ data ChatItemVersion = ChatItemVersion itemVersionTs :: UTCTime, createdAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ChatItemVersion where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion mkItemVersion ChatItem {content, meta} = version <$> ciMsgContent content @@ -1155,9 +942,7 @@ data MemberDeliveryStatus = MemberDeliveryStatus { groupMemberId :: GroupMemberId, memberDeliveryStatus :: CIStatus 'MDSnd } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MemberDeliveryStatus where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CIModeration = CIModeration { moderationId :: Int64, @@ -1166,3 +951,187 @@ data CIModeration = CIModeration moderatedAt :: UTCTime } deriving (Show) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CT") ''ChatType) + +instance ChatTypeI c => FromJSON (SChatType c) where + parseJSON v = (\(ACT t) -> checkChatType t) . aChatType <$?> J.parseJSON v + +instance ToJSON (SChatType c) where + toJSON = J.toJSON . toChatType + toEncoding = J.toEncoding . toChatType + +$(JQ.deriveJSON defaultJSON ''ChatName) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCID") ''JSONCIDeleted) + +instance ChatTypeI c => FromJSON (CIDeleted c) where + parseJSON v = (\(ACIDeleted _ x) -> checkChatType x) . jsonACIDeleted <$?> J.parseJSON v + +instance ChatTypeI c => ToJSON (CIDeleted c) where + toJSON = J.toJSON . jsonCIDeleted + toEncoding = J.toEncoding . jsonCIDeleted + +$(JQ.deriveJSON defaultJSON ''CITimed) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "SSP") ''SndCIStatusProgress) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCIS") ''JSONCIStatus) + +instance MsgDirectionI d => FromJSON (CIStatus d) where + parseJSON v = (\(ACIStatus _ s) -> checkDirection s) . jsonACIStatus <$?> J.parseJSON v + +instance ToJSON (CIStatus d) where + toJSON = J.toJSON . jsonCIStatus + toEncoding = J.toEncoding . jsonCIStatus + +instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode + +instance (Typeable d, MsgDirectionI d) => FromField (CIStatus d) where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +$(JQ.deriveJSON defaultJSON ''MemberDeliveryStatus) + +$(JQ.deriveJSON defaultJSON ''ChatItemVersion) + +$(JQ.deriveJSON defaultJSON ''ChatItemInfo) + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIMeta c d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIMeta) + +instance ChatTypeI c => ToJSON (CIMeta c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''CIMeta) + toEncoding = $(JQ.mkToEncoding defaultJSON ''CIMeta) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCIFS") ''JSONCIFileStatus) + +instance MsgDirectionI d => FromJSON (CIFileStatus d) where + parseJSON v = (\(AFS _ s) -> checkDirection s) . aciFileStatusJSON <$?> J.parseJSON v + +instance ToJSON (CIFileStatus d) where + toJSON = J.toJSON . jsonCIFileStatus + toEncoding = J.toEncoding . jsonCIFileStatus + +instance MsgDirectionI d => ToField (CIFileStatus d) where toField = toField . decodeLatin1 . strEncode + +instance FromField ACIFileStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +instance MsgDirectionI d => FromJSON (CIFile d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIFile) + +instance MsgDirectionI d => ToJSON (CIFile d) where + toJSON = $(JQ.mkToJSON defaultJSON ''CIFile) + toEncoding = $(JQ.mkToEncoding defaultJSON ''CIFile) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIDirection) + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where + parseJSON v = (\(CCID _ x') -> checkDirection x') <$?> J.parseJSON v + +instance ToJSON (CIDirection c d) where + toJSON = J.toJSON . jsonCIDirection + toEncoding = J.toEncoding . jsonCIDirection + +instance ChatTypeI c => FromJSON (CCIDirection c) where + parseJSON v = (\(ACID _ d x) -> checkChatType (CCID d x)) <$?> J.parseJSON v + +instance FromJSON ACIDirection where + parseJSON v = jsonACIDirection <$> J.parseJSON v + +instance ChatTypeI c => FromJSON (CIQDirection c) where + parseJSON v = (\(ACIQDirection _ x) -> checkChatType x) . jsonACIQDirection <$?> J.parseJSON v + +instance ToJSON (CIQDirection c) where + toJSON = J.toJSON . jsonCIQDirection + toEncoding = J.toEncoding . jsonCIQDirection + +instance ChatTypeI c => FromJSON (CIQuote c) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIQuote) + +$(JQ.deriveToJSON defaultJSON ''CIQuote) + +$(JQ.deriveJSON defaultJSON ''CIReactionCount) + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ChatItem) + +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (ChatItem c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''ChatItem) + toEncoding = $(JQ.mkToEncoding defaultJSON ''ChatItem) + +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (JSONAnyChatItem c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''JSONAnyChatItem) + toEncoding = $(JQ.mkToEncoding defaultJSON ''JSONAnyChatItem) + +instance FromJSON AChatItem where + parseJSON = J.withObject "AChatItem" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + CChatItem d chatItem <- o .: "chatItem" + pure $ AChatItem c d chatInfo chatItem + +instance ToJSON AChatItem where + toJSON (AChatItem _ _ chat item) = J.toJSON $ JSONAnyChatItem chat item + toEncoding (AChatItem _ _ chat item) = J.toEncoding $ JSONAnyChatItem chat item + +instance forall c. ChatTypeI c => FromJSON (CChatItem c) where + parseJSON v = J.withObject "CChatItem" parse v + where + parse o = do + CCID d (_ :: CIDirection c d) <- o .: "chatDir" + ci <- J.parseJSON @(ChatItem c d) v + pure $ CChatItem d ci + +instance ChatTypeI c => ToJSON (CChatItem c) where + toJSON (CChatItem _ ci) = J.toJSON ci + toEncoding (CChatItem _ ci) = J.toEncoding ci + +$(JQ.deriveJSON defaultJSON ''ChatStats) + +instance ChatTypeI c => ToJSON (Chat c) where + toJSON = $(JQ.mkToJSON defaultJSON ''Chat) + toEncoding = $(JQ.mkToEncoding defaultJSON ''Chat) + +instance FromJSON AChat where + parseJSON = J.withObject "AChat" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + chatItems <- o .: "chatItems" + chatStats <- o .: "chatStats" + pure $ AChat c Chat {chatInfo, chatItems, chatStats} + +instance ToJSON AChat where + toJSON (AChat _ c) = J.toJSON c + toEncoding (AChat _ c) = J.toEncoding c + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIReaction c d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIReaction) + +instance ChatTypeI c => ToJSON (CIReaction c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''CIReaction) + toEncoding = $(JQ.mkToEncoding defaultJSON ''CIReaction) + +instance FromJSON AnyCIReaction where + parseJSON v = J.withObject "AnyCIReaction" parse v + where + parse o = do + ACID c d (_ :: CIDirection c d) <- o .: "chatDir" + ACIR c d <$> J.parseJSON @(CIReaction c d) v + +instance ChatTypeI c => ToJSON (JSONCIReaction c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''JSONCIReaction) + toEncoding = $(JQ.mkToEncoding defaultJSON ''JSONCIReaction) + +instance FromJSON ACIReaction where + parseJSON = J.withObject "ACIReaction" $ \o -> do + ACIR c d reaction <- o .: "chatReaction" + cInfo <- o .: "chatInfo" + pure $ ACIReaction c d cInfo reaction + +instance ToJSON ACIReaction where + toJSON (ACIReaction _ _ cInfo reaction) = J.toJSON $ JSONCIReaction cInfo reaction + toEncoding (ACIReaction _ _ cInfo reaction) = J.toEncoding $ JSONCIReaction cInfo reaction + +$(JQ.deriveJSON defaultJSON ''MsgMetaJSON) + +msgMetaJson :: MsgMeta -> Text +msgMetaJson = decodeLatin1 . LB.toStrict . J.encode . msgMetaToJson diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 7836e7232a..2ca9d4ca0c 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} @@ -14,9 +13,9 @@ module Simplex.Chat.Messages.CIContent where -import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J -import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.TH as JQ +import Data.Aeson.Types as JT import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -24,25 +23,20 @@ import Data.Type.Equality import Data.Word (Word32) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) +import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, tshow, (<$?>)) data MsgDirection = MDRcv | MDSnd - deriving (Eq, Show, Generic) + deriving (Eq, Show) -instance FromJSON MsgDirection where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MD" - -instance ToJSON MsgDirection where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "MD" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MD" +$(JQ.deriveJSON (enumJSON $ dropPrefix "MD") ''MsgDirection) instance FromField AMsgDirection where fromField = fromIntField_ $ fmap fromMsgDirection . msgDirectionIntP @@ -106,14 +100,9 @@ msgDirectionIntP = \case _ -> Nothing data CIDeleteMode = CIDMBroadcast | CIDMInternal - deriving (Show, Generic) + deriving (Show) -instance ToJSON CIDeleteMode where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CIDM" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CIDM" - -instance FromJSON CIDeleteMode where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CIDM" +$(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) ciDeleteModeToText :: CIDeleteMode -> Text ciDeleteModeToText = \case @@ -163,14 +152,7 @@ ciMsgContent = \case _ -> Nothing data MsgDecryptError = MDERatchetHeader | MDETooManySkipped | MDERatchetEarlier | MDEOther - deriving (Eq, Show, Generic) - -instance ToJSON MsgDecryptError where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "MDE" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MDE" - -instance FromJSON MsgDecryptError where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MDE" + deriving (Eq, Show) ciRequiresAttention :: forall d. MsgDirectionI d => CIContent d -> Bool ciRequiresAttention content = case msgDirection @d of @@ -204,135 +186,14 @@ ciRequiresAttention content = case msgDirection @d of CIRcvModerated -> True CIInvalidJSON _ -> False -data RcvGroupEvent - = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting - | RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember - | RGEMemberLeft -- CRLeftMember - | RGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} - | RGEUserRole {role :: GroupMemberRole} - | RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember - | RGEUserDeleted -- CRDeletedMemberUser - | RGEGroupDeleted -- CRGroupDeleted - | RGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated - -- RGEInvitedViaGroupLink chat items are not received - they're created when sending group invitations, - -- but being RcvGroupEvent allows them to be assigned to the respective member (and so enable "send direct message") - -- and be created as unread without adding / working around new status for sent items - | RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink - | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv - deriving (Show, Generic) - -instance FromJSON RcvGroupEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RGE" - -instance ToJSON RcvGroupEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RGE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RGE" - -newtype DBRcvGroupEvent = RGE RcvGroupEvent - -instance FromJSON DBRcvGroupEvent where - parseJSON v = RGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RGE") v - -instance ToJSON DBRcvGroupEvent where - toJSON (RGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RGE") v - toEncoding (RGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RGE") v - -data SndGroupEvent - = SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} - | SGEUserRole {role :: GroupMemberRole} - | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember - | SGEUserLeft -- CRLeftMemberUser - | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated - deriving (Show, Generic) - -instance FromJSON SndGroupEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SGE" - -instance ToJSON SndGroupEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SGE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SGE" - -newtype DBSndGroupEvent = SGE SndGroupEvent - -instance FromJSON DBSndGroupEvent where - parseJSON v = SGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "SGE") v - -instance ToJSON DBSndGroupEvent where - toJSON (SGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SGE") v - toEncoding (SGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SGE") v - -data RcvConnEvent - = RCESwitchQueue {phase :: SwitchPhase} - | RCERatchetSync {syncStatus :: RatchetSyncState} - | RCEVerificationCodeReset - deriving (Show, Generic) - -data SndConnEvent - = SCESwitchQueue {phase :: SwitchPhase, member :: Maybe GroupMemberRef} - | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} - deriving (Show, Generic) - -instance FromJSON RcvConnEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RCE" - -instance ToJSON RcvConnEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RCE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RCE" - -newtype DBRcvConnEvent = RCE RcvConnEvent - -instance FromJSON DBRcvConnEvent where - parseJSON v = RCE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RCE") v - -instance ToJSON DBRcvConnEvent where - toJSON (RCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RCE") v - toEncoding (RCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RCE") v - -instance FromJSON SndConnEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SCE" - -instance ToJSON SndConnEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SCE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SCE" - -newtype DBSndConnEvent = SCE SndConnEvent - -instance FromJSON DBSndConnEvent where - parseJSON v = SCE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "SCE") v - -instance ToJSON DBSndConnEvent where - toJSON (SCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SCE") v - toEncoding (SCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SCE") v - -data RcvDirectEvent = - -- RDEProfileChanged {...} - RDEContactDeleted - deriving (Show, Generic) - -instance FromJSON RcvDirectEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RDE" - -instance ToJSON RcvDirectEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RDE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RDE" - -newtype DBRcvDirectEvent = RDE RcvDirectEvent - -instance FromJSON DBRcvDirectEvent where - parseJSON v = RDE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RDE") v - -instance ToJSON DBRcvDirectEvent where - toJSON (RDE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RDE") v - toEncoding (RDE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RDE") v - newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where - parseJSON v = DBME <$> J.genericParseJSON (singleFieldJSON fstToLower) v + parseJSON v = DBME <$> $(JQ.mkParseJSON (singleFieldJSON fstToLower) ''MsgErrorType) v instance ToJSON DBMsgErrorType where - toJSON (DBME v) = J.genericToJSON (singleFieldJSON fstToLower) v - toEncoding (DBME v) = J.genericToEncoding (singleFieldJSON fstToLower) v + toJSON (DBME v) = $(JQ.mkToJSON (singleFieldJSON fstToLower) ''MsgErrorType) v + toEncoding (DBME v) = $(JQ.mkToEncoding (singleFieldJSON fstToLower) ''MsgErrorType) v data CIGroupInvitation = CIGroupInvitation { groupId :: GroupId, @@ -341,25 +202,14 @@ data CIGroupInvitation = CIGroupInvitation groupProfile :: GroupProfile, status :: CIGroupInvitationStatus } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CIGroupInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data CIGroupInvitationStatus = CIGISPending | CIGISAccepted | CIGISRejected | CIGISExpired - deriving (Eq, Show, Generic) - -instance FromJSON CIGroupInvitationStatus where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CIGIS" - -instance ToJSON CIGroupInvitationStatus where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CIGIS" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CIGIS" + deriving (Eq, Show) ciContentToText :: CIContent d -> Text ciContentToText = \case @@ -685,6 +535,12 @@ ciCallInfoText status duration = case status of CISCallEnded -> "ended " <> durationText duration CISCallError -> "error" +$(JQ.deriveJSON (enumJSON $ dropPrefix "MDE") ''MsgDecryptError) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CIGIS") ''CIGroupInvitationStatus) + +$(JQ.deriveJSON defaultJSON ''CIGroupInvitation) + $(JQ.deriveJSON (enumJSON $ dropPrefix "CISCall") ''CICallStatus) -- platform specific diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs new file mode 100644 index 0000000000..42a5add1d6 --- /dev/null +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -0,0 +1,116 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Messages.CIContent.Events where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as J +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol (RatchetSyncState (..), SwitchPhase (..)) +import Simplex.Messaging.Parsers (dropPrefix, singleFieldJSON, sumTypeJSON) + +data RcvGroupEvent + = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting + | RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember + | RGEMemberLeft -- CRLeftMember + | RGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} + | RGEUserRole {role :: GroupMemberRole} + | RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember + | RGEUserDeleted -- CRDeletedMemberUser + | RGEGroupDeleted -- CRGroupDeleted + | RGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated + -- RGEInvitedViaGroupLink chat items are not received - they're created when sending group invitations, + -- but being RcvGroupEvent allows them to be assigned to the respective member (and so enable "send direct message") + -- and be created as unread without adding / working around new status for sent items + | RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink + | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv + deriving (Show) + +data SndGroupEvent + = SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} + | SGEUserRole {role :: GroupMemberRole} + | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember + | SGEUserLeft -- CRLeftMemberUser + | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated + deriving (Show) + +data RcvConnEvent + = RCESwitchQueue {phase :: SwitchPhase} + | RCERatchetSync {syncStatus :: RatchetSyncState} + | RCEVerificationCodeReset + deriving (Show) + +data SndConnEvent + = SCESwitchQueue {phase :: SwitchPhase, member :: Maybe GroupMemberRef} + | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} + deriving (Show) + +data RcvDirectEvent = + -- RDEProfileChanged {...} + RDEContactDeleted + deriving (Show) + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RGE") ''RcvGroupEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBRcvGroupEvent = RGE RcvGroupEvent + +instance FromJSON DBRcvGroupEvent where + parseJSON v = RGE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "RGE") ''RcvGroupEvent) v + +instance ToJSON DBRcvGroupEvent where + toJSON (RGE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "RGE") ''RcvGroupEvent) v + toEncoding (RGE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "RGE") ''RcvGroupEvent) v + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "SGE") ''SndGroupEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBSndGroupEvent = SGE SndGroupEvent + +instance FromJSON DBSndGroupEvent where + parseJSON v = SGE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "SGE") ''SndGroupEvent) v + +instance ToJSON DBSndGroupEvent where + toJSON (SGE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "SGE") ''SndGroupEvent) v + toEncoding (SGE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "SGE") ''SndGroupEvent) v + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RCE") ''RcvConnEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBRcvConnEvent = RCE RcvConnEvent + +instance FromJSON DBRcvConnEvent where + parseJSON v = RCE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "RCE") ''RcvConnEvent) v + +instance ToJSON DBRcvConnEvent where + toJSON (RCE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "RCE") ''RcvConnEvent) v + toEncoding (RCE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "RCE") ''RcvConnEvent) v + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "SCE") ''SndConnEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBSndConnEvent = SCE SndConnEvent + +instance FromJSON DBSndConnEvent where + parseJSON v = SCE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "SCE") ''SndConnEvent) v + +instance ToJSON DBSndConnEvent where + toJSON (SCE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "SCE") ''SndConnEvent) v + toEncoding (SCE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "SCE") ''SndConnEvent) v + +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RDE") ''RcvDirectEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBRcvDirectEvent = RDE RcvDirectEvent + +instance FromJSON DBRcvDirectEvent where + parseJSON v = RDE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "RDE") ''RcvDirectEvent) v + +instance ToJSON DBRcvDirectEvent where + toJSON (RDE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "RDE") ''RcvDirectEvent) v + toEncoding (RDE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "RDE") ''RcvDirectEvent) v diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 6203d1218c..be079af8a5 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -1,8 +1,8 @@ -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fobject-code #-} @@ -13,8 +13,8 @@ import Control.Concurrent.STM import Control.Exception (catch, SomeException) import Control.Monad.Except import Control.Monad.Reader -import Data.Aeson (ToJSON (..)) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) @@ -32,7 +32,6 @@ import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (poke) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) -import GHC.Generics (Generic) import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList) @@ -50,12 +49,26 @@ import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), Migrati import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..)) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) +data DBMigrationResult + = DBMOk + | DBMInvalidConfirmation + | DBMErrorNotADatabase {dbFile :: String} + | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} + | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} + deriving (Show) + +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "DBM") ''DBMigrationResult) + +data APIResponse = APIResponse {corr :: Maybe CorrId, remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} + +$(JQ.deriveToJSON defaultJSON ''APIResponse) + foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString @@ -189,18 +202,6 @@ defaultMobileConfig = getActiveUser_ :: SQLiteStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers -data DBMigrationResult - = DBMOk - | DBMInvalidConfirmation - | DBMErrorNotADatabase {dbFile :: String} - | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} - | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} - deriving (Show, Generic) - -instance ToJSON DBMigrationResult where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DBM" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DBM" - chatMigrateInit :: String -> String -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm @@ -264,10 +265,3 @@ chatPasswordHash pwd salt = either (const "") passwordHash salt' where salt' = U.decode salt passwordHash = U.encode . C.sha512Hash . (pwd <>) - -data APIResponse = APIResponse {corr :: Maybe CorrId, remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} - deriving (Generic) - -instance ToJSON APIResponse where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 73978549ff..99860bbfa3 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -1,7 +1,7 @@ {-# LANGUAGE BangPatterns #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} module Simplex.Chat.Mobile.File @@ -19,8 +19,8 @@ where import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class -import Data.Aeson (ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Lazy as LB @@ -32,7 +32,6 @@ import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr import Foreign.Storable (poke, pokeByteOff) -import GHC.Generics (Generic) import Simplex.Chat.Mobile.Shared import Simplex.Chat.Util (chunkSize, encryptFile) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), CryptoFileHandle, FTCryptoError (..)) @@ -45,9 +44,8 @@ import UnliftIO (Handle, IOMode (..), withFile) data WriteFileResult = WFResult {cryptoArgs :: CryptoFileArgs} | WFError {writeError :: String} - deriving (Generic) -instance ToJSON WriteFileResult where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "WF" +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "WF") ''WriteFileResult) cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString cChatWriteFile cPath ptr len = do @@ -66,9 +64,6 @@ chatWriteFile path s = do data ReadFileResult = RFResult {fileSize :: Int} | RFError {readError :: String} - deriving (Generic) - -instance ToJSON ReadFileResult where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RF" cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) cChatReadFile cPath cKey cNonce = do @@ -141,3 +136,5 @@ chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runCatchExcept runCatchExceptT :: ExceptT String IO a -> IO (Either String a) runCatchExceptT action = runExceptT action `catchAll` (pure . Left . show) + +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "RF") ''ReadFileResult) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 50d58b2a48..2687299350 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} @@ -11,6 +10,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -23,6 +23,7 @@ import Data.Aeson (FromJSON, ToJSON, (.:), (.:?), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.KeyMap as JM +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) @@ -40,13 +41,12 @@ import Data.Typeable (Typeable) import Data.Word (Word32) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) @@ -70,14 +70,9 @@ data ConnectionEntity | SndFileConnection {entityConnection :: Connection, sndFileTransfer :: SndFileTransfer} | RcvFileConnection {entityConnection :: Connection, rcvFileTransfer :: RcvFileTransfer} | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} - deriving (Eq, Show, Generic) + deriving (Eq, Show) -instance FromJSON ConnectionEntity where - parseJSON = J.genericParseJSON $ sumTypeJSON fstToLower - -instance ToJSON ConnectionEntity where - toJSON = J.genericToJSON $ sumTypeJSON fstToLower - toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''ConnectionEntity) updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity updateEntityConnStatus connEntity connStatus = case connEntity of @@ -104,8 +99,6 @@ instance MsgEncodingI 'Binary where encoding = SBinary instance MsgEncodingI 'Json where encoding = SJson -data ACMEventTag = forall e. MsgEncodingI e => ACMEventTag (SMsgEncoding e) (CMEventTag e) - instance TestEquality SMsgEncoding where testEquality SBinary SBinary = Just Refl testEquality SJson SJson = Just Refl @@ -127,7 +120,6 @@ data AppMessageJson = AppMessageJson event :: Text, params :: J.Object } - deriving (Generic, FromJSON) data AppMessageBinary = AppMessageBinary { msgId :: Maybe SharedMsgId, @@ -135,10 +127,6 @@ data AppMessageBinary = AppMessageBinary body :: ByteString } -instance ToJSON AppMessageJson where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - instance StrEncoding AppMessageBinary where strEncode AppMessageBinary {tag, msgId, body} = smpEncode (tag, msgId', Tail body) where @@ -167,20 +155,42 @@ instance ToJSON SharedMsgId where toJSON = strToJSON toEncoding = strToJEncoding +$(JQ.deriveJSON defaultJSON ''AppMessageJson) + data MsgRef = MsgRef { msgId :: Maybe SharedMsgId, sentAt :: UTCTime, sent :: Bool, memberId :: Maybe MemberId -- must be present in all group message references, both referencing sent and received } - deriving (Eq, Show, Generic) + deriving (Eq, Show) -instance FromJSON MsgRef where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} +$(JQ.deriveJSON defaultJSON ''MsgRef) -instance ToJSON MsgRef where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData, content :: Maybe LinkContent} + deriving (Eq, Show) + +data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} + deriving (Eq, Show) + +$(pure []) + +instance FromJSON LinkContent where + parseJSON v@(J.Object j) = + $(JQ.mkParseJSON (taggedObjectJSON $ dropPrefix "LC") ''LinkContent) v + <|> LCUnknown <$> j .: "type" <*> pure j + parseJSON invalid = + JT.prependFailure "bad LinkContent, " (JT.typeMismatch "Object" invalid) + +instance ToJSON LinkContent where + toJSON = \case + LCUnknown _ j -> J.Object j + v -> $(JQ.mkToJSON (taggedObjectJSON $ dropPrefix "LC") ''LinkContent) v + toEncoding = \case + LCUnknown _ j -> JE.value $ J.Object j + v -> $(JQ.mkToEncoding (taggedObjectJSON $ dropPrefix "LC") ''LinkContent) v + +$(JQ.deriveJSON defaultJSON ''LinkPreview) data ChatMessage e = ChatMessage { chatVRange :: VersionRange, @@ -191,19 +201,6 @@ data ChatMessage e = ChatMessage data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e) -instance MsgEncodingI e => StrEncoding (ChatMessage e) where - strEncode msg = case chatToAppMessage msg of - AMJson m -> LB.toStrict $ J.encode m - AMBinary m -> strEncode m - strP = (\(ACMsg _ m) -> checkEncoding m) <$?> strP - -instance StrEncoding AChatMessage where - strEncode (ACMsg _ m) = strEncode m - strP = - A.peekChar' >>= \case - '{' -> ACMsg SJson <$> ((appJsonToCM <=< J.eitherDecodeStrict') <$?> A.takeByteString) - _ -> ACMsg SBinary <$> (appBinaryToCM <$?> strP) - data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json @@ -329,11 +326,7 @@ instance Encoding InlineFileChunk where pure FileChunk {chunkNo = fromIntegral $ c2w c, chunkBytes} data QuotedMsg = QuotedMsg {msgRef :: MsgRef, content :: MsgContent} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON QuotedMsg where - toEncoding = J.genericToEncoding J.defaultOptions - toJSON = J.genericToJSON J.defaultOptions + deriving (Eq, Show) cmToQuotedMsg :: AChatMsgEvent -> Maybe QuotedMsg cmToQuotedMsg = \case @@ -386,34 +379,6 @@ isQuote = \case MCQuote {} -> True _ -> False -data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData, content :: Maybe LinkContent} - deriving (Eq, Show, Generic) - -data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} - deriving (Eq, Show, Generic) - -instance FromJSON LinkPreview where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} - -instance ToJSON LinkPreview where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance FromJSON LinkContent where - parseJSON v@(J.Object j) = - J.genericParseJSON (taggedObjectJSON $ dropPrefix "LC") v - <|> LCUnknown <$> j .: "type" <*> pure j - parseJSON invalid = - JT.prependFailure "bad LinkContent, " (JT.typeMismatch "Object" invalid) - -instance ToJSON LinkContent where - toJSON = \case - LCUnknown _ j -> J.Object j - v -> J.genericToJSON (taggedObjectJSON $ dropPrefix "LC") v - toEncoding = \case - LCUnknown _ j -> JE.value $ J.Object j - v -> J.genericToEncoding (taggedObjectJSON $ dropPrefix "LC") v - data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} @@ -466,6 +431,21 @@ msgContentTag = \case data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} deriving (Eq, Show) +$(JQ.deriveJSON defaultJSON ''QuotedMsg) + +instance MsgEncodingI e => StrEncoding (ChatMessage e) where + strEncode msg = case chatToAppMessage msg of + AMJson m -> LB.toStrict $ J.encode m + AMBinary m -> strEncode m + strP = (\(ACMsg _ m) -> checkEncoding m) <$?> strP + +instance StrEncoding AChatMessage where + strEncode (ACMsg _ m) = strEncode m + strP = + A.peekChar' >>= \case + '{' -> ACMsg SJson <$> ((appJsonToCM <=< J.eitherDecodeStrict') <$?> A.takeByteString) + _ -> ACMsg SBinary <$> (appBinaryToCM <$?> strP) + parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = MCQuote <$> v .: "quote" <*> mc @@ -545,6 +525,8 @@ instance ToField MsgContent where instance FromField MsgContent where fromField = fromTextField_ decodeJSON +data ACMEventTag = forall e. MsgEncodingI e => ACMEventTag (SMsgEncoding e) (CMEventTag e) + data CMEventTag (e :: MsgEncoding) where XMsgNew_ :: CMEventTag 'Json XMsgFileDescr_ :: CMEventTag 'Json diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index 65a851f718..aa4ebe5952 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -1,5 +1,4 @@ {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index de54813a4d..d16955199e 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,6 +1,5 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE TemplateHaskell #-} @@ -12,7 +11,7 @@ import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import UnliftIO data RemoteHostClient = RemoteHostClient @@ -116,10 +115,10 @@ $(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) $(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) -$(J.deriveJSON J.defaultOptions ''RemoteCtrlOOB) +$(J.deriveJSON defaultJSON ''RemoteCtrlOOB) -$(J.deriveJSON J.defaultOptions ''RemoteHostInfo) +$(J.deriveJSON defaultJSON ''RemoteHostInfo) -$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrl) +$(J.deriveJSON defaultJSON ''RemoteCtrl) -$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrlInfo) +$(J.deriveJSON defaultJSON ''RemoteCtrlInfo) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index d4ca3193b3..99689b29d1 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -1,10 +1,10 @@ {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} @@ -61,8 +61,7 @@ where import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class -import Data.Aeson (FromJSON, ToJSON) -import qualified Data.Aeson as J +import qualified Data.Aeson.TH as J import Data.Functor (($>)) import Data.Int (Int64) import qualified Data.List.NonEmpty as L @@ -73,7 +72,6 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) -import GHC.Generics (Generic) import Simplex.Chat.Call import Simplex.Chat.Messages import Simplex.Chat.Protocol @@ -86,6 +84,7 @@ import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (safeDecodeUtf8) @@ -400,17 +399,17 @@ data UserContactLink = UserContactLink { connReqContact :: ConnReqContact, autoAccept :: Maybe AutoAccept } - deriving (Show, Generic, FromJSON) - -instance ToJSON UserContactLink where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AutoAccept = AutoAccept { acceptIncognito :: IncognitoEnabled, autoReply :: Maybe MsgContent } - deriving (Show, Generic, FromJSON) + deriving (Show) -instance ToJSON AutoAccept where toEncoding = J.genericToEncoding J.defaultOptions +$(J.deriveJSON defaultJSON ''AutoAccept) + +$(J.deriveJSON defaultJSON ''UserContactLink) toUserContactLink :: (ConnReqContact, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink toUserContactLink (connReq, autoAccept, acceptIncognito, autoReply) = diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index d00dce718e..7c1f07191d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -1,11 +1,11 @@ {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store.Shared where @@ -16,8 +16,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG, randomBytesGenerate) -import Data.Aeson (FromJSON, ToJSON) -import qualified Data.Aeson as J +import qualified Data.Aeson.TH as J import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) @@ -28,7 +27,6 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), Query, SQLError, (:.) (..)) import qualified Database.SQLite.Simple as SQL import Database.SQLite.Simple.QQ (sql) -import GHC.Generics (Generic) import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Remote.Types @@ -103,14 +101,9 @@ data StoreError | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} | SERemoteHostNotFound {remoteHostId :: RemoteHostId} | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} - deriving (Show, Exception, Generic) + deriving (Show, Exception) -instance FromJSON StoreError where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SE" - -instance ToJSON StoreError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SE" +$(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b3c4ea09b2..057067d85a 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -2,7 +2,6 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -45,14 +44,13 @@ import Database.SQLite.Simple.FromField (returnError, FromField(..)) import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util ((<$?>)) import Simplex.Messaging.Version @@ -264,9 +262,7 @@ data UserContact = UserContact connReqContact :: ConnReqContact, groupId :: Maybe GroupId } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON UserContact where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) userContactGroupId :: UserContact -> Maybe GroupId userContactGroupId UserContact {groupId} = groupId @@ -284,10 +280,7 @@ data UserContactRequest = UserContactRequest updatedAt :: UTCTime, xContactId :: Maybe XContactId } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON UserContactRequest where - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) newtype XContactId = XContactId ByteString deriving (Eq, Show) @@ -341,9 +334,7 @@ optionalFullName displayName fullName | otherwise = " (" <> fullName <> ")" data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON Group where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) type GroupId = Int64 @@ -359,9 +350,7 @@ data GroupInfo = GroupInfo updatedAt :: UTCTime, chatTs :: Maybe UTCTime } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g @@ -369,9 +358,7 @@ groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary { currentMembers :: Int } - deriving (Show, Generic, FromJSON) - -instance ToJSON GroupSummary where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ContactOrGroup = CGContact Contact | CGGroup Group @@ -386,9 +373,7 @@ data ChatSettings = ChatSettings sendRcpts :: Maybe Bool, favorite :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) defaultChatSettings :: ChatSettings defaultChatSettings = @@ -402,18 +387,7 @@ chatHasNtfs :: ChatSettings -> Bool chatHasNtfs ChatSettings {enableNtfs} = enableNtfs /= MFNone data MsgFilter = MFNone | MFAll | MFMentions - deriving (Eq, Show, Generic) - -instance FromJSON MsgFilter where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MF" - -instance ToJSON MsgFilter where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "MF" - -instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP - -instance ToField MsgFilter where toField = toField . msgFilterInt + deriving (Eq, Show) msgFilterInt :: MsgFilter -> Int msgFilterInt = \case @@ -496,11 +470,7 @@ data Profile = Profile -- - incognito -- - local_alias } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON Profile where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool @@ -522,11 +492,7 @@ data LocalProfile = LocalProfile preferences :: Maybe Preferences, localAlias :: LocalAlias } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON LocalProfile where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) localProfileId :: LocalProfile -> ProfileId localProfileId LocalProfile{profileId} = profileId @@ -546,11 +512,7 @@ data GroupProfile = GroupProfile image :: Maybe ImageData, groupPreferences :: Maybe GroupPreferences } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupProfile where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) newtype ImageData = ImageData Text deriving (Eq, Show) @@ -567,14 +529,6 @@ instance ToField ImageData where toField (ImageData t) = toField t instance FromField ImageData where fromField = fmap ImageData . fromField data CReqClientData = CRDataGroup {groupLinkId :: GroupLinkId} - deriving (Generic) - -instance ToJSON CReqClientData where - toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "CRData" - toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "CRData" - -instance FromJSON CReqClientData where - parseJSON = J.genericParseJSON . taggedObjectJSON $ dropPrefix "CRData" newtype GroupLinkId = GroupLinkId {unGroupLinkId :: ByteString} -- used to identify invitation via group link deriving (Eq, Show) @@ -602,29 +556,19 @@ data GroupInvitation = GroupInvitation groupProfile :: GroupProfile, groupLinkId :: Maybe GroupLinkId } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MemberIdRole where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data IntroInvitation = IntroInvitation { groupConnReq :: ConnReqInvitation, directConnReq :: Maybe ConnReqInvitation } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON IntroInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data MemberInfo = MemberInfo { memberId :: MemberId, @@ -632,11 +576,7 @@ data MemberInfo = MemberInfo v :: Maybe ChatVersionRange, profile :: Profile } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MemberInfo where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = @@ -675,16 +615,10 @@ data GroupMember = GroupMember memberContactProfileId :: ProfileId, activeConn :: Maybe Connection } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupMember where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data GroupMemberRef = GroupMemberRef {groupMemberId :: Int64, profile :: Profile} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupMemberRef where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) groupMemberRef :: GroupMember -> GroupMemberRef groupMemberRef GroupMember {groupMemberId, memberProfile = p} = @@ -744,14 +678,7 @@ instance ToJSON MemberId where toEncoding = strToJEncoding data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown - deriving (Eq, Show, Generic) - -instance FromJSON InvitedBy where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "IB" - -instance ToJSON InvitedBy where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" + deriving (Eq, Show) toInvitedBy :: Int64 -> Maybe Int64 -> InvitedBy toInvitedBy userCtId (Just ctId) @@ -803,9 +730,7 @@ instance ToJSON GroupMemberRole where data GroupMemberSettings = GroupMemberSettings { showMessages :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupMemberSettings where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) defaultMemberSettings :: GroupMemberSettings defaultMemberSettings = GroupMemberSettings {showMessages = True} @@ -986,9 +911,7 @@ data SndFileTransfer = SndFileTransfer fileDescrId :: Maybe Int64, fileInline :: Maybe InlineFileMode } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON SndFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) sndFileTransferConnId :: SndFileTransfer -> ConnId sndFileTransferConnId SndFileTransfer {agentConnId = AgentConnId acId} = acId @@ -1003,24 +926,10 @@ data FileInvitation = FileInvitation fileInline :: Maybe InlineFileMode, fileDescr :: Maybe FileDescr } - deriving (Eq, Show, Generic) - -instance ToJSON FileInvitation where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - -instance FromJSON FileInvitation where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data FileDescr = FileDescr {fileDescrText :: Text, fileDescrPartNo :: Int, fileDescrComplete :: Bool} - deriving (Eq, Show, Generic) - -instance ToJSON FileDescr where - toEncoding = J.genericToEncoding J.defaultOptions - toJSON = J.genericToJSON J.defaultOptions - -instance FromJSON FileDescr where - parseJSON = J.genericParseJSON J.defaultOptions + deriving (Eq, Show) xftpFileInvitation :: FilePath -> Integer -> FileDescr -> FileInvitation xftpFileInvitation fileName fileSize fileDescr = @@ -1036,7 +945,7 @@ xftpFileInvitation fileName fileSize fileDescr = data InlineFileMode = IFMOffer -- file will be sent inline once accepted | IFMSent -- file is sent inline without acceptance - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance TextEncoding InlineFileMode where textEncode = \case @@ -1072,18 +981,14 @@ data RcvFileTransfer = RcvFileTransfer -- SMP files are encrypted after all chunks are received cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data XFTPRcvFile = XFTPRcvFile { rcvFileDescription :: RcvFileDescr, agentRcvFileId :: Maybe AgentRcvFileId, agentRcvFileDeleted :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON XFTPRcvFile where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data RcvFileDescr = RcvFileDescr { fileDescrId :: Int64, @@ -1091,9 +996,7 @@ data RcvFileDescr = RcvFileDescr fileDescrPartNo :: Int, fileDescrComplete :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON RcvFileDescr where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data RcvFileStatus = RFSNew @@ -1101,14 +1004,7 @@ data RcvFileStatus | RFSConnected RcvFileInfo | RFSComplete RcvFileInfo | RFSCancelled (Maybe RcvFileInfo) - deriving (Eq, Show, Generic) - -instance FromJSON RcvFileStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" - -instance ToJSON RcvFileStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" + deriving (Eq, Show) rcvFileComplete :: RcvFileStatus -> Bool rcvFileComplete = \case @@ -1123,9 +1019,7 @@ data RcvFileInfo = RcvFileInfo connId :: Maybe Int64, agentConnId :: Maybe AgentConnId } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) liveRcvFileTransferInfo :: RcvFileTransfer -> Maybe RcvFileInfo liveRcvFileTransferInfo RcvFileTransfer {fileStatus} = case fileStatus of @@ -1226,14 +1120,7 @@ data FileTransfer sndFileTransfers :: [SndFileTransfer] } | FTRcv {rcvFileTransfer :: RcvFileTransfer} - deriving (Show, Generic) - -instance FromJSON FileTransfer where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "FT" - -instance ToJSON FileTransfer where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" + deriving (Show) data FileTransferMeta = FileTransferMeta { fileId :: FileTransferId, @@ -1245,9 +1132,7 @@ data FileTransferMeta = FileTransferMeta chunkSize :: Integer, cancelled :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FileTransferMeta where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data XFTPSndFile = XFTPSndFile { agentSndFileId :: AgentSndFileId, @@ -1255,9 +1140,7 @@ data XFTPSndFile = XFTPSndFile agentSndFileDeleted :: Bool, cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON XFTPSndFile where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) fileTransferCancelled :: FileTransfer -> Bool fileTransferCancelled (FTSnd FileTransferMeta {cancelled} _) = cancelled @@ -1318,7 +1201,7 @@ data Connection = Connection authErrCounter :: Int, createdAt :: UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show) connReady :: Connection -> Bool connReady Connection {connStatus} = connStatus == ConnReady || connStatus == ConnSndReady @@ -1330,9 +1213,7 @@ connDisabled :: Connection -> Bool connDisabled Connection {authErrCounter} = authErrCounter >= authErrDisableCount data SecurityCode = SecurityCode {securityCode :: Text, verifiedAt :: UTCTime} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON SecurityCode where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) verificationCode :: ByteString -> Text verificationCode = T.pack . unwords . chunks 5 . show . os2ip @@ -1351,13 +1232,6 @@ aConnId Connection {agentConnId = AgentConnId cId} = cId connIncognito :: Connection -> Bool connIncognito Connection {customUserProfileId} = isJust customUserProfileId -instance FromJSON Connection where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} - -instance ToJSON Connection where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - data PendingContactConnection = PendingContactConnection { pccConnId :: Int64, pccAgentConnId :: AgentConnId, @@ -1371,13 +1245,11 @@ data PendingContactConnection = PendingContactConnection createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) aConnId' :: PendingContactConnection -> ConnId aConnId' PendingContactConnection {pccAgentConnId = AgentConnId cId} = cId -instance ToJSON PendingContactConnection where toEncoding = J.genericToEncoding J.defaultOptions - data ConnStatus = -- | connection is created by initiating party with agent NEW command (createConnection) ConnNew @@ -1512,7 +1384,7 @@ data NetworkStatus | NSConnected | NSDisconnected | NSError {connectionError :: String} - deriving (Eq, Ord, Show, Generic) + deriving (Eq, Ord, Show) netStatusStr :: NetworkStatus -> String netStatusStr = \case @@ -1521,20 +1393,11 @@ netStatusStr = \case NSDisconnected -> "disconnected" NSError e -> "error: " <> e -instance FromJSON NetworkStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "NS" - -instance ToJSON NetworkStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "NS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "NS" - data ConnNetworkStatus = ConnNetworkStatus { agentConnId :: AgentConnId, networkStatus :: NetworkStatus } - deriving (Show, Generic, FromJSON) - -instance ToJSON ConnNetworkStatus where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) type CommandId = Int64 @@ -1548,7 +1411,7 @@ data CommandStatus = CSCreated | CSCompleted -- unused - was replaced with deleteCommand | CSError -- internal command error, e.g. not matching connection id or unexpected response, not related to agent message ERR - deriving (Show, Generic) + deriving (Show) instance FromField CommandStatus where fromField = fromTextField_ textDecode @@ -1575,7 +1438,7 @@ data CommandFunction | CFAcceptContact | CFAckMessage | CFDeleteConn -- not used - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance FromField CommandFunction where fromField = fromTextField_ textDecode @@ -1641,14 +1504,7 @@ data ServerCfg p = ServerCfg tested :: Maybe Bool, enabled :: Bool } - deriving (Show, Generic) - -instance ProtocolTypeI p => ToJSON (ServerCfg p) where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - -instance ProtocolTypeI p => FromJSON (ServerCfg p) where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Show) newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRange} deriving (Eq, Show) @@ -1674,14 +1530,95 @@ instance ToJSON JVersionRange where toJSON (JVersionRange (VersionRange minV maxV)) = J.object ["minVersion" .= minV, "maxVersion" .= maxV] toEncoding (JVersionRange (VersionRange minV maxV)) = J.pairs $ "minVersion" .= minV <> "maxVersion" .= maxV -$(JQ.deriveJSON defOpts ''UserPwdHash) +$(JQ.deriveJSON defaultJSON ''UserContact) -$(JQ.deriveJSON defOpts ''User) +$(JQ.deriveJSON defaultJSON ''Profile) -$(JQ.deriveJSON defOpts ''NewUser) +$(JQ.deriveJSON defaultJSON ''LocalProfile) -$(JQ.deriveJSON defOpts ''UserInfo) +$(JQ.deriveJSON defaultJSON ''UserContactRequest) -$(JQ.deriveJSON defOpts ''Contact) +$(JQ.deriveJSON defaultJSON ''GroupProfile) -$(JQ.deriveJSON defOpts ''ContactRef) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "IB") ''InvitedBy) + +$(JQ.deriveJSON defaultJSON ''GroupMemberSettings) + +$(JQ.deriveJSON defaultJSON ''SecurityCode) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "NS") ''NetworkStatus) + +$(JQ.deriveJSON defaultJSON ''ConnNetworkStatus) + +$(JQ.deriveJSON defaultJSON ''Connection) + +$(JQ.deriveJSON defaultJSON ''PendingContactConnection) + +$(JQ.deriveJSON defaultJSON ''GroupMember) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter) + +$(JQ.deriveJSON defaultJSON ''ChatSettings) + +$(JQ.deriveJSON defaultJSON ''GroupInfo) + +$(JQ.deriveJSON defaultJSON ''Group) + +$(JQ.deriveJSON defaultJSON ''GroupSummary) + +instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP + +instance ToField MsgFilter where toField = toField . msgFilterInt + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "CRData") ''CReqClientData) + +$(JQ.deriveJSON defaultJSON ''MemberIdRole) + +$(JQ.deriveJSON defaultJSON ''GroupInvitation) + +$(JQ.deriveJSON defaultJSON ''IntroInvitation) + +$(JQ.deriveJSON defaultJSON ''MemberInfo) + +$(JQ.deriveJSON defaultJSON ''GroupMemberRef) + +$(JQ.deriveJSON defaultJSON ''FileDescr) + +$(JQ.deriveJSON defaultJSON ''FileInvitation) + +$(JQ.deriveJSON defaultJSON ''SndFileTransfer) + +$(JQ.deriveJSON defaultJSON ''RcvFileDescr) + +$(JQ.deriveJSON defaultJSON ''XFTPRcvFile) + +$(JQ.deriveJSON defaultJSON ''RcvFileInfo) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RFS") ''RcvFileStatus) + +$(JQ.deriveJSON defaultJSON ''RcvFileTransfer) + +$(JQ.deriveJSON defaultJSON ''XFTPSndFile) + +$(JQ.deriveJSON defaultJSON ''FileTransferMeta) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FT") ''FileTransfer) + +$(JQ.deriveJSON defaultJSON ''UserPwdHash) + +$(JQ.deriveJSON defaultJSON ''User) + +$(JQ.deriveJSON defaultJSON ''NewUser) + +$(JQ.deriveJSON defaultJSON ''UserInfo) + +$(JQ.deriveJSON defaultJSON ''Contact) + +$(JQ.deriveJSON defaultJSON ''ContactRef) + +instance ProtocolTypeI p => ToJSON (ServerCfg p) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''ServerCfg) + toJSON = $(JQ.mkToJSON defaultJSON ''ServerCfg) + +instance ProtocolTypeI p => FromJSON (ServerCfg p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ServerCfg) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index c7555e18a8..da13da742f 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -12,6 +11,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilyDependencies #-} @@ -24,7 +24,7 @@ module Simplex.Chat.Types.Preferences where import Control.Applicative ((<|>)) import Data.Aeson (FromJSON (..), ToJSON (..)) -import qualified Data.Aeson as J +import qualified Data.Aeson.TH as J import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, isJust) @@ -32,11 +32,10 @@ import Data.Text (Text) import qualified Data.Text as T import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import GHC.Records.Compat import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) data ChatFeature @@ -45,7 +44,7 @@ data ChatFeature | CFReactions | CFVoice | CFCalls - deriving (Show, Generic) + deriving (Show) data SChatFeature (f :: ChatFeature) where SCFTimedMessages :: SChatFeature 'CFTimedMessages @@ -71,13 +70,6 @@ chatFeatureNameText = \case chatFeatureNameText' :: SChatFeature f -> Text chatFeatureNameText' = chatFeatureNameText . chatFeature -instance ToJSON ChatFeature where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CF" - -instance FromJSON ChatFeature where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CF" - allChatFeatures :: [AChatFeature] allChatFeatures = [ ACF SCFTimedMessages, @@ -149,17 +141,7 @@ data Preferences = Preferences voice :: Maybe VoicePreference, calls :: Maybe CallsPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON Preferences where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance ToField Preferences where - toField = toField . encodeJSON - -instance FromField Preferences where - fromField = fromTextField_ decodeJSON + deriving (Eq, Show) data GroupFeature = GFTimedMessages @@ -168,7 +150,7 @@ data GroupFeature | GFReactions | GFVoice | GFFiles - deriving (Show, Generic) + deriving (Show) data SGroupFeature (f :: GroupFeature) where SGFTimedMessages :: SGroupFeature 'GFTimedMessages @@ -200,13 +182,6 @@ groupFeatureAllowed' :: GroupFeatureI f => SGroupFeature f -> FullGroupPreferenc groupFeatureAllowed' feature prefs = getField @"enable" (getGroupPreference feature prefs) == FEOn -instance ToJSON GroupFeature where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "GF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "GF" - -instance FromJSON GroupFeature where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "GF" - allGroupFeatures :: [AGroupFeature] allGroupFeatures = [ AGF SGFTimedMessages, @@ -263,17 +238,7 @@ data GroupPreferences = GroupPreferences voice :: Maybe VoiceGroupPreference, files :: Maybe FilesGroupPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupPreferences where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance ToField GroupPreferences where - toField = toField . encodeJSON - -instance FromField GroupPreferences where - fromField = fromTextField_ decodeJSON + deriving (Eq, Show) setGroupPreference :: forall f. GroupFeatureI f => SGroupFeature f -> GroupFeatureEnabled -> Maybe GroupPreferences -> GroupPreferences setGroupPreference f enable prefs_ = setGroupPreference_ f pref prefs @@ -312,9 +277,7 @@ data FullPreferences = FullPreferences voice :: VoicePreference, calls :: CallsPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullPreferences where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) -- full collection of group preferences defined in the app - it is used to ensure we include all preferences and to simplify processing -- if some of the preferences are not defined in GroupPreferences, defaults from defaultGroupPrefs are used here. @@ -326,9 +289,7 @@ data FullGroupPreferences = FullGroupPreferences voice :: VoiceGroupPreference, files :: FilesGroupPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullGroupPreferences where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) -- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences data ContactUserPreferences = ContactUserPreferences @@ -338,30 +299,17 @@ data ContactUserPreferences = ContactUserPreferences voice :: ContactUserPreference VoicePreference, calls :: ContactUserPreference CallsPreference } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data ContactUserPreference p = ContactUserPreference { enabled :: PrefEnabled, userPreference :: ContactUserPref p, contactPreference :: p } - deriving (Eq, Show, Generic) + deriving (Eq, Show) data ContactUserPref p = CUPContact {preference :: p} | CUPUser {preference :: p} - deriving (Eq, Show, Generic) - -instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions - -instance FromJSON p => FromJSON (ContactUserPreference p) where parseJSON = J.genericParseJSON J.defaultOptions - -instance ToJSON p => ToJSON (ContactUserPreference p) where toEncoding = J.genericToEncoding J.defaultOptions - -instance FromJSON p => FromJSON (ContactUserPref p) where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "CUP" - -instance ToJSON p => ToJSON (ContactUserPref p) where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CUP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP" + deriving (Eq, Show) toChatPrefs :: FullPreferences -> Preferences toChatPrefs FullPreferences {timedMessages, fullDelete, reactions, voice, calls} = @@ -404,31 +352,19 @@ data TimedMessagesPreference = TimedMessagesPreference { allow :: FeatureAllowed, ttl :: Maybe Int } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON TimedMessagesPreference where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data FullDeletePreference = FullDeletePreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullDeletePreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data ReactionsPreference = ReactionsPreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ReactionsPreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data VoicePreference = VoicePreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON VoicePreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CallsPreference = CallsPreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallsPreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) class (Eq (FeaturePreference f), HasField "allow" (FeaturePreference f) FeatureAllowed) => FeatureI f where type FeaturePreference (f :: ChatFeature) = p | p -> f @@ -477,47 +413,33 @@ instance FeatureI 'CFCalls where data GroupPreference = GroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data TimedMessagesGroupPreference = TimedMessagesGroupPreference { enable :: GroupFeatureEnabled, ttl :: Maybe Int } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data DirectMessagesGroupPreference = DirectMessagesGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data FullDeleteGroupPreference = FullDeleteGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data ReactionsGroupPreference = ReactionsGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data VoiceGroupPreference = VoiceGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data FilesGroupPreference = FilesGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON TimedMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON DirectMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON ReactionsGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON FullDeleteGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON VoiceGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON FilesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference f) GroupFeatureEnabled) => GroupFeatureI f where type GroupFeaturePreference (f :: GroupFeature) = p | p -> f @@ -619,7 +541,7 @@ data FeatureAllowed = FAAlways -- allow unconditionally | FAYes -- allow, if peer allows it | FANo -- do not allow - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode @@ -645,7 +567,7 @@ instance ToJSON FeatureAllowed where toEncoding = strToJEncoding data GroupFeatureEnabled = FEOn | FEOff - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode @@ -718,11 +640,7 @@ toGroupPreferences groupPreferences = pref f = Just $ getGroupPreference f groupPreferences data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON PrefEnabled where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) prefEnabled :: FeatureI f => Bool -> FeaturePreference f -> FeaturePreference f -> PrefEnabled prefEnabled asymmetric user contact = case (getField @"allow" user, getField @"allow" contact) of @@ -784,3 +702,69 @@ getContactUserPreference f ps = case f of SCFReactions -> ps.reactions SCFVoice -> ps.voice SCFCalls -> ps.calls + +$(J.deriveJSON (enumJSON $ dropPrefix "CF") ''ChatFeature) + +$(J.deriveJSON (enumJSON $ dropPrefix "GF") ''GroupFeature) + +$(J.deriveJSON defaultJSON ''TimedMessagesPreference) + +$(J.deriveJSON defaultJSON ''FullDeletePreference) + +$(J.deriveJSON defaultJSON ''ReactionsPreference) + +$(J.deriveJSON defaultJSON ''VoicePreference) + +$(J.deriveJSON defaultJSON ''CallsPreference) + +$(J.deriveJSON defaultJSON ''Preferences) + +instance ToField Preferences where + toField = toField . encodeJSON + +instance FromField Preferences where + fromField = fromTextField_ decodeJSON + +$(J.deriveJSON defaultJSON ''GroupPreference) + +$(J.deriveJSON defaultJSON ''TimedMessagesGroupPreference) + +$(J.deriveJSON defaultJSON ''DirectMessagesGroupPreference) + +$(J.deriveJSON defaultJSON ''ReactionsGroupPreference) + +$(J.deriveJSON defaultJSON ''FullDeleteGroupPreference) + +$(J.deriveJSON defaultJSON ''VoiceGroupPreference) + +$(J.deriveJSON defaultJSON ''FilesGroupPreference) + +$(J.deriveJSON defaultJSON ''GroupPreferences) + +instance ToField GroupPreferences where + toField = toField . encodeJSON + +instance FromField GroupPreferences where + fromField = fromTextField_ decodeJSON + +$(J.deriveJSON defaultJSON ''FullPreferences) + +$(J.deriveJSON defaultJSON ''FullGroupPreferences) + +$(J.deriveJSON defaultJSON ''PrefEnabled) + +instance FromJSON p => FromJSON (ContactUserPref p) where + parseJSON = $(J.mkParseJSON (sumTypeJSON $ dropPrefix "CUP") ''ContactUserPref) + +instance ToJSON p => ToJSON (ContactUserPref p) where + toJSON = $(J.mkToJSON (sumTypeJSON $ dropPrefix "CUP") ''ContactUserPref) + toEncoding = $(J.mkToEncoding (sumTypeJSON $ dropPrefix "CUP") ''ContactUserPref) + +instance FromJSON p => FromJSON (ContactUserPreference p) where + parseJSON = $(J.mkParseJSON defaultJSON ''ContactUserPreference) + +instance ToJSON p => ToJSON (ContactUserPreference p) where + toJSON = $(J.mkToJSON defaultJSON ''ContactUserPreference) + toEncoding = $(J.mkToEncoding defaultJSON ''ContactUserPreference) + +$(J.deriveJSON defaultJSON ''ContactUserPreferences) diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs index 8681e99086..fffdd24b9e 100644 --- a/src/Simplex/Chat/Types/Util.hs +++ b/src/Simplex/Chat/Types/Util.hs @@ -28,6 +28,3 @@ fromBlobField_ p = \case Right k -> Ok k Left e -> returnError ConversionFailed f ("could not parse field: " ++ e) f -> returnError ConversionFailed f "expecting SQLBlob column type" - -defOpts :: J.Options -defOpts = J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 501e232a60..2a3b74da37 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,5 +1,4 @@ {-# LANGUAGE DataKinds #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} @@ -7,12 +6,13 @@ {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.View where -import Data.Aeson (ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) @@ -31,7 +31,6 @@ import Data.Time (LocalTime (..), TimeOfDay (..), TimeZone (..), utcToLocalTime) import Data.Time.Calendar (addDays) import Data.Time.Clock (UTCTime) import Data.Time.Format (defaultTimeLocale, formatTime) -import GHC.Generics (Generic) import qualified Network.HTTP.Types as Q import Numeric (showFFloat) import Simplex.Chat (defaultChatConfig, maxImageSize) @@ -66,6 +65,13 @@ import System.Console.ANSI.Types type CurrentTime = UTCTime +data WCallCommand + = WCCallStart {media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} + | WCCallOffer {offer :: Text, iceCandidates :: Text, media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} + | WCCallAnswer {answer :: Text, iceCandidates :: Text} + +$(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "WCCall") ''WCallCommand) + serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> String serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ @@ -1633,16 +1639,6 @@ supporedBrowsers callType | encryptedCall callType = " (only Chrome and Safari support e2e encryption for WebRTC, Safari may require enabling WebRTC insertable streams)" | otherwise = "" -data WCallCommand - = WCCallStart {media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} - | WCCallOffer {offer :: Text, iceCandidates :: Text, media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} - | WCCallAnswer {answer :: Text, iceCandidates :: Text} - deriving (Generic) - -instance ToJSON WCallCommand where - toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "WCCall" - toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "WCCall" - viewVersionInfo :: ChatLogLevel -> CoreVersionInfo -> [StyledString] viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCommit} = map plain $ diff --git a/stack.yaml b/stack.yaml index f0fcbab1de..58a921303b 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: d920a2504b6d4653748da7d297cb13cd0a0f1f48 + commit: 511d793b927b1e2f12999e0829718671b3a8f0cb - github: kazu-yamamoto/http2 commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index aa36b397a3..d8e98513c7 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,6 +1,7 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -fno-warn-orphans #-} @@ -9,8 +10,9 @@ module MobileTests where import ChatTests.Utils import Control.Monad.Except import Crypto.Random (getRandomBytes) -import Data.Aeson (FromJSON (..)) +import Data.Aeson (FromJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as BS @@ -256,9 +258,11 @@ testMediaCApi _ = do (f cKeyStr ptr cLen >>= peekCAString) `shouldReturn` "" getByteString ptr cLen -instance FromJSON WriteFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "WF" +instance FromJSON WriteFileResult where + parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "WF") ''WriteFileResult) -instance FromJSON ReadFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RF" +instance FromJSON ReadFileResult where + parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "RF") ''ReadFileResult) testFileCApi :: FilePath -> FilePath -> IO () testFileCApi fileName tmp = do From 7102723c23ed73c668f717f60e45cbf2354098d8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 26 Oct 2023 18:51:45 +0400 Subject: [PATCH 122/294] ios: create new group with incognito membership (#3284) * ios: create new group with incognito membership * layout * fix button * update layout * layout * layout --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- apps/ios/Shared/Views/Chat/ChatView.swift | 38 +++- .../Views/Chat/Group/GroupChatInfoView.swift | 12 +- .../Views/Chat/Group/GroupLinkView.swift | 40 +++- .../Shared/Views/NewChat/AddGroupView.swift | 171 +++++++++++------- .../Views/NewChat/PasteToConnectView.swift | 8 +- .../Views/NewChat/ScanToConnectView.swift | 6 +- apps/ios/SimpleXChat/APITypes.swift | 4 +- 8 files changed, 191 insertions(+), 92 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 680a7132df..de09853e12 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1017,9 +1017,9 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } -func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { +func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo { let userId = try currentUserId("apiNewGroup") - let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) + let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) if case let .groupCreated(_, groupInfo) = r { return groupInfo } throw r } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 21af0ebe17..5679b451a0 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -37,6 +37,10 @@ struct ChatView: View { @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon @State private var selectedMember: GroupMember? = nil + // opening GroupLinkView on link button (incognito) + @State private var showGroupLinkSheet: Bool = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if #available(iOS 16.0, *) { @@ -173,9 +177,16 @@ struct ChatView: View { HStack { if groupInfo.canAddMembers { if (chat.chatInfo.incognito) { - Image(systemName: "person.crop.circle.badge.plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) } + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } } else { addMembersButton() .appSheet(isPresented: $showAddMembersSheet) { @@ -417,7 +428,26 @@ struct ChatView: View { Image(systemName: "person.crop.circle.badge.plus") } } - + + private func groupLinkButton() -> some View { + Button { + if case let .group(gInfo) = chat.chatInfo { + Task { + do { + if let link = try apiGetGroupLink(gInfo.groupId) { + (groupLink, groupLinkMemberRole) = link + } + } catch let error { + logger.error("ChatView apiGetGroupLink: \(responseError(error))") + } + showGroupLinkSheet = true + } + } + } label: { + Image(systemName: "link.badge.plus") + } + } + private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { if loadingItems || firstPage { return } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 3b9ef347e8..dd2392b6dc 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -225,9 +225,15 @@ struct GroupChatInfoView: View { private func groupLinkButton() -> some View { NavigationLink { - GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole) - .navigationBarTitle("Group link") - .navigationBarTitleDisplayMode(.large) + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: false, + creatingGroup: false + ) + .navigationBarTitle("Group link") + .navigationBarTitleDisplayMode(.large) } label: { if groupLink == nil { Label("Create group link", systemImage: "link.badge.plus") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 781870bf5e..bf2179bea4 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -13,6 +13,9 @@ struct GroupLinkView: View { var groupId: Int64 @Binding var groupLink: String? @Binding var groupLinkMemberRole: GroupMemberRole + var showTitle: Bool = false + var creatingGroup: Bool = false + var linkCreatedCb: (() -> Void)? = nil @State private var creatingLink = false @State private var alert: GroupLinkAlert? @@ -29,10 +32,35 @@ struct GroupLinkView: View { } var body: some View { + if creatingGroup { + NavigationView { + groupLinkView() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Continue") { linkCreatedCb?() } + } + } + } + } else { + groupLinkView() + } + } + + private func groupLinkView() -> some View { List { - Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Group { + if showTitle { + Text("Group link") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + } + Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Section { if let groupLink = groupLink { Picker("Initial role", selection: $groupLinkMemberRole) { @@ -48,8 +76,10 @@ struct GroupLinkView: View { Label("Share link", systemImage: "square.and.arrow.up") } - Button(role: .destructive) { alert = .deleteLink } label: { - Label("Delete link", systemImage: "trash") + if !creatingGroup { + Button(role: .destructive) { alert = .deleteLink } label: { + Label("Delete link", systemImage: "trash") + } } } else { Button(action: createGroupLink) { diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 186a24e995..22bf1c4096 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct AddGroupView: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss: DismissAction + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @State private var chat: Chat? @State private var groupInfo: GroupInfo? @State private var profile = GroupProfile(displayName: "", fullName: "") @@ -21,18 +22,35 @@ struct AddGroupView: View { @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil @State private var showInvalidNameAlert = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if let chat = chat, let groupInfo = groupInfo { - AddGroupMembersViewCommon( - chat: chat, - groupInfo: groupInfo, - creatingGroup: true, - showFooterCounter: false - ) { _ in - dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - m.chatId = groupInfo.id + if !groupInfo.membership.memberIncognito { + AddGroupMembersViewCommon( + chat: chat, + groupInfo: groupInfo, + creatingGroup: true, + showFooterCounter: false + ) { _ in + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } + } + } else { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: true + ) { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } } } } else { @@ -41,77 +59,62 @@ struct AddGroupView: View { } func createGroupView() -> some View { - VStack(alignment: .leading) { - Text("Create secret group") - .font(.largeTitle) - .padding(.vertical, 4) - Text("The group is fully decentralized – it is visible only to the members.") - .padding(.bottom, 4) + List { + Group { + Text("Create secret group") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 24) + .onTapGesture(perform: hideKeyboard) - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your chat profile will be sent to group members").font(.footnote) - } - .padding(.bottom) - - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - profileImageView(profile.image) - if profile.image != nil { - Button { - profile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground)) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 128, maxHeight: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } } } + + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable } - - editImageButton { showChooseSource = true } + .frame(maxWidth: .infinity, alignment: .center) } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 4) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - ZStack(alignment: .topLeading) { - let name = profile.displayName.trimmingCharacters(in: .whitespaces) - if name != mkValidName(name) { - Button { - showInvalidNameAlert = true - } label: { - Image(systemName: "exclamationmark.circle").foregroundColor(.red) - } - } else { - Image(systemName: "exclamationmark.circle").foregroundColor(.clear) + Section { + groupNameTextField() + Button(action: createGroup) { + settingsRow("checkmark", color: .accentColor) { Text("Create group") } } - textField("Enter group name…", text: $profile.displayName) - .focused($focusDisplayName) - .submitLabel(.go) - .onSubmit { - if canCreateProfile() { createGroup() } - } + .disabled(!canCreateProfile()) + IncognitoToggle(incognitoEnabled: $incognitoDefault) + } footer: { + VStack(alignment: .leading, spacing: 4) { + sharedGroupProfileInfo(incognitoDefault) + Text("Fully decentralized – visible only to members.") + } + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture(perform: hideKeyboard) } - .padding(.bottom) - - Spacer() - - Button { - createGroup() - } label: { - Text("Create") - Image(systemName: "greaterthan") - } - .disabled(!canCreateProfile()) - .frame(maxWidth: .infinity, alignment: .trailing) } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { focusDisplayName = true } } - .padding() .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true @@ -141,20 +144,48 @@ struct AddGroupView: View { profile.image = nil } } - .contentShape(Rectangle()) - .onTapGesture { hideKeyboard() } + } + + func groupNameTextField() -> some View { + ZStack(alignment: .leading) { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidNameAlert = true + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "pencil").foregroundColor(.secondary) + } + textField("Enter group name…", text: $profile.displayName) + .focused($focusDisplayName) + .submitLabel(.continue) + .onSubmit { + if canCreateProfile() { createGroup() } + } + } } func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { TextField(placeholder, text: text) - .padding(.leading, 32) + .padding(.leading, 36) + } + + func sharedGroupProfileInfo(_ incognito: Bool) -> Text { + let name = ChatModel.shared.currentUser?.displayName ?? "" + return Text( + incognito + ? "A new random profile will be shared." + : "Your profile **\(name)** will be shared." + ) } func createGroup() { hideKeyboard() do { profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) - let gInfo = try apiNewGroup(profile) + let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile) Task { let groupMembers = await apiListMembers(gInfo.groupId) await MainActor.run { diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index af84f19fec..7c272fb631 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -54,9 +54,11 @@ struct PasteToConnectView: View { IncognitoToggle(incognitoEnabled: $incognitoDefault) } footer: { - sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + VStack(alignment: .leading, spacing: 4) { + sharedProfileInfo(incognitoDefault) + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + } + .frame(maxWidth: .infinity, alignment: .leading) } } .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index c55ba1502e..9a11eee92b 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -38,11 +38,11 @@ struct ScanToConnectView: View { ) .padding(.top) - Group { + VStack(alignment: .leading, spacing: 4) { sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") } + .frame(maxWidth: .infinity, alignment: .leading) .font(.footnote) .foregroundColor(.secondary) .padding(.horizontal) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5c7220f374..e270674784 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -50,7 +50,7 @@ public enum ChatCommand { case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiDeleteToken(token: DeviceToken) case apiGetNtfMessage(nonce: String, encNtfInfo: String) - case apiNewGroup(userId: Int64, groupProfile: GroupProfile) + case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) @@ -175,7 +175,7 @@ public enum ChatCommand { case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" - case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))" + case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" From a7b5dfb74c087ba729e235952b165e20beed3e45 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 27 Oct 2023 09:33:59 +0400 Subject: [PATCH 123/294] android: create new group with incognito membership (#3285) --- .../chat/simplex/common/model/SimpleXAPI.kt | 8 +- .../simplex/common/views/chat/ChatView.kt | 41 +++++++-- .../common/views/chat/group/GroupLinkView.kt | 60 ++++++++++--- .../common/views/newchat/AddGroupView.kt | 90 ++++++++++--------- .../common/views/newchat/PasteToConnect.kt | 6 +- .../commonMain/resources/MR/base/strings.xml | 4 +- 6 files changed, 138 insertions(+), 71 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 1abc823c08..b751cb56c5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1166,9 +1166,9 @@ object ChatController { } } - suspend fun apiNewGroup(p: GroupProfile): GroupInfo? { + suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } - val r = sendCmd(CC.ApiNewGroup(userId, p)) + val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile)) if (r is CR.GroupCreated) return r.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null @@ -1889,7 +1889,7 @@ sealed class CC { class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() - class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC() + class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() @@ -1999,7 +1999,7 @@ sealed class CC { is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" - is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}" + is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 94f9a6b54e..ac7161044f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -389,6 +389,16 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, + openGroupLink = { groupInfo -> + hideKeyboard(view) + withApi { + val link = chatModel.controller.apiGetGroupLink(groupInfo.groupId) + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { + GroupLinkView(chatModel, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) + } + } + }, markRead = { range, unreadCountAfter -> chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) ntfManager.cancelNotificationsForChat(chat.id) @@ -449,6 +459,7 @@ fun ChatLayout( setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, addMembers: (GroupInfo) -> Unit, + openGroupLink: (GroupInfo) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, @@ -495,7 +506,7 @@ fun ChatLayout( } Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, changeNtfsState, onSearchValueChanged) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, @@ -526,6 +537,7 @@ fun ChatInfoToolbar( startCall: (CallMediaType) -> Unit, endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, + openGroupLink: (GroupInfo) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, ) { @@ -607,13 +619,24 @@ fun ChatInfoToolbar( }) } } - } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) { - barButtons.add { - IconButton({ - showMenu.value = false - addMembers(chat.chatInfo.groupInfo) - }) { - Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) { + if (!chat.chatInfo.incognito) { + barButtons.add { + IconButton({ + showMenu.value = false + addMembers(chat.chatInfo.groupInfo) + }) { + Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } + } + } else { + barButtons.add { + IconButton({ + showMenu.value = false + openGroupLink(chat.chatInfo.groupInfo) + }) { + Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary) + } } } } @@ -1341,6 +1364,7 @@ fun PreviewChatLayout() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, + openGroupLink = {}, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, @@ -1411,6 +1435,7 @@ fun PreviewGroupChatLayout() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, + openGroupLink = {}, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 7e1c03130a..809c7c2fd6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -23,7 +23,15 @@ import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @Composable -fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit) { +fun GroupLinkView( + chatModel: ChatModel, + groupInfo: GroupInfo, + connReqContact: String?, + memberRole: GroupMemberRole?, + onGroupLinkUpdated: ((Pair?) -> Unit)?, + creatingGroup: Boolean = false, + close: (() -> Unit)? = null +) { var groupLink by rememberSaveable { mutableStateOf(connReqContact) } val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) } var creatingLink by rememberSaveable { mutableStateOf(false) } @@ -34,7 +42,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second - onGroupLinkUpdated(link) + onGroupLinkUpdated?.invoke(link) } creatingLink = false } @@ -58,7 +66,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second - onGroupLinkUpdated(link) + onGroupLinkUpdated?.invoke(link) } } } @@ -73,13 +81,15 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) if (r) { groupLink = null - onGroupLinkUpdated(null) + onGroupLinkUpdated?.invoke(null) } } }, destructive = true, ) - } + }, + creatingGroup = creatingGroup, + close = close ) if (creatingLink) { ProgressIndicator() @@ -94,8 +104,19 @@ fun GroupLinkLayout( creatingLink: Boolean, createLink: () -> Unit, updateLink: () -> Unit, - deleteLink: () -> Unit + deleteLink: () -> Unit, + creatingGroup: Boolean = false, + close: (() -> Unit)? = null ) { + @Composable + fun ContinueButton(close: () -> Unit) { + SimpleButton( + stringResource(MR.strings.continue_to_next_step), + icon = painterResource(MR.images.ic_check), + click = close + ) + } + Column( Modifier .verticalScroll(rememberScrollState()), @@ -112,7 +133,16 @@ fun GroupLinkLayout( verticalArrangement = Arrangement.SpaceEvenly ) { if (groupLink == null) { - SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) + ) { + SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink) + if (creatingGroup && close != null) { + ContinueButton(close) + } + } } else { RoleSelectionRow(groupInfo, groupLinkMemberRole) var initialLaunch by remember { mutableStateOf(true) } @@ -134,12 +164,16 @@ fun GroupLinkLayout( icon = painterResource(MR.images.ic_share), click = { clipboard.shareText(simplexChatLink(groupLink)) } ) - SimpleButton( - stringResource(MR.strings.delete_link), - icon = painterResource(MR.images.ic_delete), - color = Color.Red, - click = deleteLink - ) + if (creatingGroup && close != null) { + ContinueButton(close) + } else { + SimpleButton( + stringResource(MR.strings.delete_link), + icon = painterResource(MR.images.ic_delete), + color = Color.Red, + click = deleteLink + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index be446f6087..9b2cedefaa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.newchat +import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -11,10 +12,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.buildAnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -22,11 +22,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.ReadableText -import chat.simplex.common.views.usersettings.DeleteImageButton -import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.platform.* import chat.simplex.common.views.* +import chat.simplex.common.views.chat.group.GroupLinkView +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -35,9 +34,9 @@ import java.net.URI @Composable fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { AddGroupLayout( - createGroup = { groupProfile -> + createGroup = { incognito, groupProfile -> withApi { - val groupInfo = chatModel.controller.apiNewGroup(groupProfile) + val groupInfo = chatModel.controller.apiNewGroup(incognito, groupProfile) if (groupInfo != null) { chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() @@ -45,24 +44,36 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { chatModel.chatId.value = groupInfo.id setGroupMembers(groupInfo, chatModel) close.invoke() - ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, true, chatModel, close) + if (!groupInfo.incognito) { + ModalManager.end.showModalCloseable(true) { close -> + AddGroupMembersView(groupInfo, creatingGroup = true, chatModel, close) + } + } else { + ModalManager.end.showModalCloseable(true) { close -> + GroupLinkView(chatModel, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) + } } } } }, + incognitoPref = chatModel.controller.appPrefs.incognito, close ) } @Composable -fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { +fun AddGroupLayout( + createGroup: (Boolean, GroupProfile) -> Unit, + incognitoPref: SharedPreference, + close: () -> Unit +) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val displayName = rememberSaveable { mutableStateOf("") } val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } + val incognito = remember { mutableStateOf(incognitoPref.get()) } ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { ModalBottomSheetLayout( @@ -87,7 +98,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { .padding(horizontal = DEFAULT_PADDING) ) { AppBarTitle(stringResource(MR.strings.create_secret_group_title)) - ReadableText(MR.strings.group_is_decentralized, TextAlign.Center) Box( Modifier .fillMaxWidth() @@ -118,20 +128,32 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { } ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) Spacer(Modifier.height(8.dp)) - val enabled = canCreateProfile(displayName.value) - if (enabled) { - CreateGroupButton(MaterialTheme.colors.primary, Modifier - .clickable { - createGroup(GroupProfile( - displayName = displayName.value.trim(), - fullName = "", - image = profileImage.value - )) - } - .padding(8.dp)) - } else { - CreateGroupButton(MaterialTheme.colors.secondary, Modifier.padding(8.dp)) - } + + SettingsActionItem( + painterResource(MR.images.ic_check), + stringResource(MR.strings.create_group_button), + click = { + createGroup(incognito.value, GroupProfile( + displayName = displayName.value.trim(), + fullName = "", + image = profileImage.value + )) + }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = !canCreateProfile(displayName.value) + ) + + IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } + + SectionTextFooter( + buildAnnotatedString { + append(sharedProfileInfo(chatModel, incognito.value)) + append("\n") + append(annotatedStringResource(MR.strings.group_is_decentralized)) + } + ) + LaunchedEffect(Unit) { delay(300) focusRequester.requestFocus() @@ -142,21 +164,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { } } -@Composable -fun CreateGroupButton(color: Color, modifier: Modifier) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) { - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = color, fontWeight = FontWeight.Bold) - Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = color) - } - } - } -} - fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmpty() && isValidDisplayName(displayName.trim()) @Preview @@ -164,7 +171,8 @@ fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmp fun PreviewAddGroupLayout() { SimpleXTheme { AddGroupLayout( - createGroup = {}, + createGroup = { _, _ -> }, + incognitoPref = SharedPreference({ false }, {}), close = {} ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index f7a5a1e86b..b142b8e16a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -3,10 +3,10 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview -import chat.simplex.common.platform.Log import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.painterResource @@ -14,7 +14,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp -import chat.simplex.common.platform.TAG import chat.simplex.common.model.ChatModel import chat.simplex.common.model.SharedPreference import chat.simplex.common.ui.theme.* @@ -23,7 +22,6 @@ import chat.simplex.common.views.usersettings.IncognitoView import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR import java.net.URI -import java.net.URISyntaxException @Composable fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { @@ -97,6 +95,8 @@ fun PasteToConnectLayout( painterResource(MR.images.ic_link), stringResource(MR.strings.connect_button), click = { connectViaLink(connectionLink.value) }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, disabled = connectionLink.value.isEmpty() || connectionLink.value.trim().contains(" ") ) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b5ffd6630f..aa76a768e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1294,11 +1294,11 @@ Create secret group - The group is fully decentralized – it is visible only to the members. + Fully decentralized – visible only to members. Enter group name: Group full name: Your chat profile will be sent to group members - + Create group Group profile is stored on members\' devices, not on the servers. From 9568279b0f286a14666f82404345775030af02ad Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 29 Oct 2023 18:21:51 +0000 Subject: [PATCH 124/294] update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Messages/CIContent.hs | 8 +++++--- stack.yaml | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cabal.project b/cabal.project index 5c19fcd446..9522fe2337 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: d920a2504b6d4653748da7d297cb13cd0a0f1f48 + tag: 0410948b56ea630dfa86441bbcf8ec97aeb1df01 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26188aa774..ebce4b5c03 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."d920a2504b6d4653748da7d297cb13cd0a0f1f48" = "0r53wn01z044h6myvd458n3hiqsz64kpv59khgybzwdw5mmqnp34"; + "https://github.com/simplex-chat/simplexmq.git"."0410948b56ea630dfa86441bbcf8ec97aeb1df01" = "1y4a28dkccbv8cbh164iirsnxa62qwac0pd5c8lqr5kddqvkz970"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index d3cdbcf3e4..639093d01b 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -9,12 +9,14 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -314,11 +316,11 @@ instance ToJSON DBRcvDirectEvent where newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where - parseJSON v = DBME <$> J.genericParseJSON (singleFieldJSON fstToLower) v + parseJSON v = DBME <$> $(JQ.mkParseJSON (singleFieldJSON fstToLower) ''MsgErrorType) v instance ToJSON DBMsgErrorType where - toJSON (DBME v) = J.genericToJSON (singleFieldJSON fstToLower) v - toEncoding (DBME v) = J.genericToEncoding (singleFieldJSON fstToLower) v + toJSON (DBME v) = $(JQ.mkToJSON (singleFieldJSON fstToLower) ''MsgErrorType) v + toEncoding (DBME v) = $(JQ.mkToEncoding (singleFieldJSON fstToLower) ''MsgErrorType) v data CIGroupInvitation = CIGroupInvitation { groupId :: GroupId, diff --git a/stack.yaml b/stack.yaml index f0fcbab1de..7ab6474893 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: d920a2504b6d4653748da7d297cb13cd0a0f1f48 + commit: 0410948b56ea630dfa86441bbcf8ec97aeb1df01 - github: kazu-yamamoto/http2 commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher From d90da57f1283e0c2f3ec18f9e9dfa77a8b1d4074 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 29 Oct 2023 19:06:32 +0000 Subject: [PATCH 125/294] core: store/get remote files (#3289) * core: store remote files (wip) * fix/test store remote file * get remote file * get file * validate remote file metadata before sending to controller * CLI commands, test * update store method --- simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 84 +++++----- src/Simplex/Chat/Archive.hs | 1 + src/Simplex/Chat/Controller.hs | 30 ++-- src/Simplex/Chat/Files.hs | 27 ++++ src/Simplex/Chat/Messages.hs | 5 +- src/Simplex/Chat/Mobile/File.hs | 2 +- src/Simplex/Chat/Remote.hs | 137 ++++++++++++---- src/Simplex/Chat/Remote/Protocol.hs | 107 ++++++------- src/Simplex/Chat/Remote/Transport.hs | 27 ++++ src/Simplex/Chat/Remote/Types.hs | 22 ++- src/Simplex/Chat/Store/Files.hs | 53 ++++-- src/Simplex/Chat/View.hs | 43 +++-- tests/RemoteTests.hs | 230 ++++++++++++++++++++------- 14 files changed, 543 insertions(+), 227 deletions(-) create mode 100644 src/Simplex/Chat/Files.hs create mode 100644 src/Simplex/Chat/Remote/Transport.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e9036ea604..f831bf540c 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -32,6 +32,7 @@ library Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Core + Simplex.Chat.Files Simplex.Chat.Help Simplex.Chat.Markdown Simplex.Chat.Messages @@ -131,6 +132,7 @@ library Simplex.Chat.Remote.Discovery Simplex.Chat.Remote.Multicast Simplex.Chat.Remote.Protocol + Simplex.Chat.Remote.Transport Simplex.Chat.Remote.Types Simplex.Chat.Store Simplex.Chat.Store.Connections diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e8049dcbb5..e46a426d8c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -55,6 +55,7 @@ import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Chat.Call import Simplex.Chat.Controller +import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent @@ -104,7 +105,7 @@ import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version import System.Exit (exitFailure, exitSuccess) -import System.FilePath (combine, splitExtensions, takeFileName, ()) +import System.FilePath (takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) import System.Random (randomRIO) import Text.Read (readMaybe) @@ -213,6 +214,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen currentCalls <- atomically TM.empty localDeviceName <- newTVarIO "" -- TODO set in config remoteHostSessions <- atomically TM.empty + remoteHostsFolder <- newTVarIO Nothing remoteCtrlSession <- newTVarIO Nothing filesFolder <- newTVarIO optFilesFolder chatStoreChanged <- newTVarIO False @@ -246,6 +248,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen currentCalls, localDeviceName, remoteHostSessions, + remoteHostsFolder, remoteCtrlSession, config, filesFolder, @@ -394,7 +397,7 @@ execChatCommand rh s = do case parseChatCommand s of Left e -> pure $ chatCmdError u e Right cmd -> case rh of - Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId s + Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId cmd s _ -> execChatCommand_ u cmd execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse @@ -403,8 +406,8 @@ execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` c execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd -execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ByteString -> m ChatResponse -execRemoteCommand u rhId s = handleCommandError u $ getRemoteHostSession rhId >>= \rh -> processRemoteCommand rhId rh s +execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ChatCommand -> ByteString -> m ChatResponse +execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostSession rhId >>= \rh -> processRemoteCommand rhId rh cmd s handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catch` (pure . Left . mkChatError)) @@ -542,6 +545,10 @@ processChatCommand = \case createDirectoryIfMissing True ff asks filesFolder >>= atomically . (`writeTVar` Just ff) ok_ + SetRemoteHostsFolder rf -> do + createDirectoryIfMissing True rf + chatWriteVar remoteHostsFolder $ Just rf + ok_ APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ @@ -1795,15 +1802,15 @@ processChatCommand = \case asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName - processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCFile "") - SendImage chatName f -> withUser $ \user -> do + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCFile "") + SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName - filePath <- toFSFilePath f - unless (any (`isSuffixOf` map toLower f) imageExtensions) $ throwChatError CEFileImageType {filePath} + filePath <- toFSFilePath fPath + unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCImage "" fixedImagePreview) + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" @@ -1905,19 +1912,21 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} - SetLocalDeviceName name -> withUser $ \_ -> chatWriteVar localDeviceName name >> ok_ + SetLocalDeviceName name -> withUser_ $ chatWriteVar localDeviceName name >> ok_ CreateRemoteHost -> CRRemoteHostCreated <$> createRemoteHost ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts StartRemoteHost rh -> startRemoteHost rh >> ok_ StopRemoteHost rh -> closeRemoteHostSession rh >> ok_ DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ - StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_ - RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) - AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_ - RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_ - StopRemoteCtrl -> stopRemoteCtrl >> ok_ - ListRemoteCtrls -> CRRemoteCtrlList <$> listRemoteCtrls - DeleteRemoteCtrl rc -> deleteRemoteCtrl rc >> ok_ + StoreRemoteFile rh encrypted_ localPath -> CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath + GetRemoteFile rh rf -> getRemoteFile rh rf >> ok_ + StartRemoteCtrl -> withUser_ $ startRemoteCtrl (execChatCommand Nothing) >> ok_ + RegisterRemoteCtrl oob -> withUser_ $ CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) + AcceptRemoteCtrl rc -> withUser_ $ acceptRemoteCtrl rc >> ok_ + RejectRemoteCtrl rc -> withUser_ $ rejectRemoteCtrl rc >> ok_ + StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ + ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls + DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) @@ -2173,14 +2182,14 @@ processChatCommand = \case withServerProtocol p action = case userProtocol p of Just Dict -> action _ -> throwChatError $ CEServerProtocol $ AProtocolType p - forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse + forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> m ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do withStore (\db -> getFileTransfer db user fileId) >>= \case - FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> forward filePath - FTSnd {fileTransferMeta = FileTransferMeta {filePath}} -> forward filePath + FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}, cryptoArgs} -> forward filePath cryptoArgs + FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \f -> f.cryptoArgs _ -> throwChatError CEFileNotReceived {fileId} where - forward = processChatCommand . sendCommand chatName + forward path cfArgs = processChatCommand . sendCommand chatName $ CryptoFile path cfArgs getGroupAndMemberId :: User -> GroupName -> ContactName -> m (GroupId, GroupMemberId) getGroupAndMemberId user gName groupMemberName = withStore $ \db -> do @@ -2575,10 +2584,9 @@ startReceivingFile user fileId = do getRcvFilePath :: forall m. ChatMonad m => FileTransferId -> Maybe FilePath -> String -> Bool -> m FilePath getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of Nothing -> - asks filesFolder >>= readTVarIO >>= \case - Nothing -> do - dir <- (`combine` "Downloads") <$> getHomeDirectory - ifM (doesDirectoryExist dir) (pure dir) getChatTempDirectory + chatReadVar filesFolder >>= \case + Nothing -> + getDefaultFilesFolder >>= (`uniqueCombine` fn) >>= createEmptyFile Just filesFolder -> @@ -2607,18 +2615,6 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of getTmpHandle :: FilePath -> m Handle getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show) -uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath -uniqueCombine filePath fileName = tryCombine (0 :: Int) - where - tryCombine n = - let (name, ext) = splitExtensions fileName - suffix = if n == 0 then "" else "_" <> show n - f = filePath `combine` (name <> suffix <> ext) - in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) - -getChatTempDirectory :: ChatMonad m => m FilePath -getChatTempDirectory = chatReadVar tempDirectory >>= maybe getTemporaryDirectory pure - acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do subMode <- chatReadVar subscriptionMode @@ -5575,6 +5571,9 @@ withUser :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser action = withUser' $ \user -> ifM chatStarted (action user) (throwChatError CEChatNotStarted) +withUser_ :: ChatMonad m => m ChatResponse -> m ChatResponse +withUser_ = withUser . const + withUserId :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse withUserId userId action = withUser $ \user -> do checkSameUser userId user @@ -5635,6 +5634,7 @@ chatCommandP = "/_resubscribe all" $> ResubscribeAllConnections, "/_temp_folder " *> (SetTempFolder <$> filePath), ("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath), + "/remote_hosts_folder " *> (SetRemoteHostsFolder <$> filePath), "/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))), "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))), "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), @@ -5809,8 +5809,8 @@ chatCommandP = "/show" *> (ShowLiveItems <$> (A.space *> onOffP <|> pure True)), "/show " *> (ShowChatItem . Just <$> A.decimal), "/item info " *> (ShowChatItemInfo <$> chatNameP <* A.space <*> msgTextP), - ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath), - ("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath), + ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> cryptoFileP), + ("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> cryptoFileP), ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal), ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal), ("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath), @@ -5858,6 +5858,8 @@ chatCommandP = "/start remote host " *> (StartRemoteHost <$> A.decimal), "/stop remote host " *> (StopRemoteHost <$> A.decimal), "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), + "/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath), + "/get remote file " *> (GetRemoteFile <$> A.decimal <* A.space <*> jsonP), "/start remote ctrl" $> StartRemoteCtrl, "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP <* A.space <*> textP)), "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), @@ -5932,6 +5934,10 @@ chatCommandP = msgTextP = jsonP <|> textP stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString filePath = stringP + cryptoFileP = do + cfArgs <- optional $ CFArgs <$> (" key=" *> strP <* A.space) <*> (" nonce=" *> strP) + path <- filePath + pure $ CryptoFile path cfArgs memberRole = A.choice [ " owner" $> GROwner, diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index e0de971bda..dd098e016d 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Archive importArchive, deleteStorage, sqlCipherExport, + archiveFilesFolder, ) where diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 66ab513a0d..bc4cfaaf89 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -178,6 +178,7 @@ data ChatController = ChatController currentCalls :: TMap ContactId Call, localDeviceName :: TVar Text, remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts + remoteHostsFolder :: TVar (Maybe FilePath), -- folder for remote hosts data remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, @@ -224,6 +225,7 @@ data ChatCommand | ResubscribeAllConnections | SetTempFolder FilePath | SetFilesFolder FilePath + | SetRemoteHostsFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool @@ -393,8 +395,8 @@ data ChatCommand | ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI) | ShowChatItemInfo ChatName Text | ShowLiveItems Bool - | SendFile ChatName FilePath - | SendImage ChatName FilePath + | SendFile ChatName CryptoFile + | SendImage ChatName CryptoFile | ForwardFile ChatName FileTransferId | ForwardImage ChatName FileTransferId | SendFileDescription ChatName FilePath @@ -419,6 +421,8 @@ data ChatCommand -- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host | StopRemoteHost RemoteHostId -- ^ Shut down a running session | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data + | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} + | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake | ListRemoteCtrls @@ -440,22 +444,27 @@ allowRemoteCommand = \case StartChat {} -> False APIStopChat -> False APIActivateChat -> False - APISuspendChat {} -> False - SetTempFolder {} -> False + APISuspendChat _ -> False + SetTempFolder _ -> False QuitChat -> False CreateRemoteHost -> False ListRemoteHosts -> False - StartRemoteHost {} -> False + StartRemoteHost _ -> False -- SwitchRemoteHost {} -> False - StopRemoteHost {} -> False - DeleteRemoteHost {} -> False + StoreRemoteFile {} -> False + GetRemoteFile {} -> False + StopRemoteHost _ -> False + DeleteRemoteHost _ -> False RegisterRemoteCtrl {} -> False StartRemoteCtrl -> False ListRemoteCtrls -> False - AcceptRemoteCtrl {} -> False - RejectRemoteCtrl {} -> False + AcceptRemoteCtrl _ -> False + RejectRemoteCtrl _ -> False StopRemoteCtrl -> False - DeleteRemoteCtrl {} -> False + DeleteRemoteCtrl _ -> False + ExecChatStoreSQL _ -> False + ExecAgentStoreSQL _ -> False + SlowSQLQueries -> False _ -> True data ChatResponse @@ -627,6 +636,7 @@ data ChatResponse | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} + | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation diff --git a/src/Simplex/Chat/Files.hs b/src/Simplex/Chat/Files.hs new file mode 100644 index 0000000000..845b237cdf --- /dev/null +++ b/src/Simplex/Chat/Files.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} + +module Simplex.Chat.Files where + +import Control.Monad.IO.Class +import Simplex.Chat.Controller +import Simplex.Messaging.Util (ifM) +import System.FilePath (splitExtensions, combine) +import UnliftIO.Directory (doesFileExist, getTemporaryDirectory, getHomeDirectory, doesDirectoryExist) + +uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath +uniqueCombine fPath fName = tryCombine (0 :: Int) + where + tryCombine n = + let (name, ext) = splitExtensions fName + suffix = if n == 0 then "" else "_" <> show n + f = fPath `combine` (name <> suffix <> ext) + in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) + +getChatTempDirectory :: ChatMonad m => m FilePath +getChatTempDirectory = chatReadVar tempDirectory >>= maybe getTemporaryDirectory pure + +getDefaultFilesFolder :: ChatMonad m => m FilePath +getDefaultFilesFolder = do + dir <- (`combine` "Downloads") <$> getHomeDirectory + ifM (doesDirectoryExist dir) (pure dir) getChatTempDirectory diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 2718b088ba..8ea33e0abb 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -24,6 +24,7 @@ import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Char (isSpace) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) @@ -53,7 +54,7 @@ data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection data ChatName = ChatName {chatType :: ChatType, chatName :: Text} deriving (Show) -chatTypeStr :: ChatType -> String +chatTypeStr :: ChatType -> Text chatTypeStr = \case CTDirect -> "@" CTGroup -> "#" @@ -61,7 +62,7 @@ chatTypeStr = \case CTContactConnection -> ":" chatNameStr :: ChatName -> String -chatNameStr (ChatName cType name) = chatTypeStr cType <> T.unpack name +chatNameStr (ChatName cType name) = T.unpack $ chatTypeStr cType <> if T.any isSpace name then "'" <> name <> "'" else name data ChatRef = ChatRef ChatType Int64 deriving (Eq, Show, Ord) diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 99860bbfa3..1da64a3044 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -99,7 +99,7 @@ chatEncryptFile fromPath toPath = either WFError WFResult <$> runCatchExceptT encrypt where encrypt = do - cfArgs <- liftIO $ CF.randomArgs + cfArgs <- liftIO CF.randomArgs encryptFile fromPath toPath cfArgs pure cfArgs diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index c195b4631d..5344c4bea6 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -16,7 +16,7 @@ import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class -import Control.Monad.Reader (asks) +import Control.Monad.Reader import Control.Monad.STM (retry) import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J @@ -34,21 +34,35 @@ import Data.Word (Word32) import Network.HTTP2.Server (responseStreaming) import qualified Network.HTTP.Types as N import Network.Socket (SockAddr (..), hostAddressToTuple) +import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller +import Simplex.Chat.Files +import Simplex.Chat.Messages (chatNameStr) import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Protocol +import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Files import Simplex.Chat.Store.Remote +import Simplex.Chat.Store.Shared +import Simplex.Chat.Types (User (..)) +import Simplex.Chat.Util (encryptFile) +import Simplex.FileTransfer.Description (FileDigest (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String (StrEncoding (..)) -import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) -import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) -import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=)) -import System.FilePath (()) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=), (<$$>)) +import System.FilePath ((), takeFileName) import UnliftIO +import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) +import Data.Functor (($>)) +import Control.Applicative ((<|>)) -- * Desktop side @@ -110,7 +124,7 @@ startRemoteHost rhId = do toView $ CRRemoteHostConnected RemoteHostInfo { remoteHostId = rhId, storePath = storePath, - displayName = remoteDeviceName remoteHostClient, + displayName = hostDeviceName remoteHostClient, remoteCtrlOOB = RemoteCtrlOOB {fingerprint, displayName=rcName}, sessionActive = True } @@ -178,9 +192,57 @@ deleteRemoteHost rhId = do Nothing -> logWarn "Local file store not available while deleting remote host" withStore' (`deleteRemoteHostRecord` rhId) -processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ByteString -> m ChatResponse -processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} s = liftRH remoteHostId $ remoteSend rhc s -processRemoteCommand _ _ _ = pure $ chatCmdError Nothing "remote command sent before session started" +storeRemoteFile :: forall m. ChatMonad m => RemoteHostId -> Maybe Bool -> FilePath -> m CryptoFile +storeRemoteFile rhId encrypted_ localPath = do + RemoteHostSession {remoteHostClient, storePath} <- getRemoteHostSession rhId + case remoteHostClient of + Nothing -> throwError $ ChatErrorRemoteHost rhId RHMissing + Just c@RemoteHostClient {encryptHostFiles} -> do + let encrypt = fromMaybe encryptHostFiles encrypted_ + cf@CryptoFile {filePath} <- if encrypt then encryptLocalFile else pure $ CF.plain localPath + filePath' <- liftRH rhId $ remoteStoreFile c filePath (takeFileName localPath) + hf_ <- chatReadVar remoteHostsFolder + forM_ hf_ $ \hf -> do + let rhf = hf storePath archiveFilesFolder + hPath = rhf takeFileName filePath' + createDirectoryIfMissing True rhf + (if encrypt then renameFile else copyFile) filePath hPath + pure (cf :: CryptoFile) {filePath = filePath'} + where + encryptLocalFile :: m CryptoFile + encryptLocalFile = do + tmpDir <- getChatTempDirectory + createDirectoryIfMissing True tmpDir + tmpFile <- tmpDir `uniqueCombine` takeFileName localPath + cfArgs <- liftIO CF.randomArgs + liftError (ChatError . CEFileWrite tmpFile) $ encryptFile localPath tmpFile cfArgs + pure $ CryptoFile tmpFile $ Just cfArgs + +getRemoteFile :: ChatMonad m => RemoteHostId -> RemoteFile -> m () +getRemoteFile rhId rf = do + RemoteHostSession {remoteHostClient, storePath} <- getRemoteHostSession rhId + case remoteHostClient of + Nothing -> throwError $ ChatErrorRemoteHost rhId RHMissing + Just c -> do + dir <- ( storePath archiveFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar remoteHostsFolder) + createDirectoryIfMissing True dir + liftRH rhId $ remoteGetFile c dir rf + +processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ChatCommand -> ByteString -> m ChatResponse +processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} cmd s = case cmd of + SendFile chatName f -> sendFile "/f" chatName f + SendImage chatName f -> sendFile "/img" chatName f + _ -> liftRH remoteHostId $ remoteSend rhc s + where + sendFile cmdName chatName (CryptoFile path cfArgs) = do + -- don't encrypt in host if already encrypted locally + CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path + let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption + liftRH remoteHostId $ remoteSend rhc $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + cryptoFileStr CryptoFile {filePath, cryptoArgs} = + maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs + <> encodeUtf8 (T.pack filePath) +processRemoteCommand _ _ _ _ = pure $ chatCmdError Nothing "remote command sent before session started" liftRH :: ChatMonad m => RemoteHostId -> ExceptT RemoteProtocolError IO a -> m a liftRH rhId = liftError (ChatErrorRemoteHost rhId . RHProtocolError) @@ -218,20 +280,24 @@ handleRemoteCommand :: forall m . ChatMonad m => (ByteString -> m ChatResponse) handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" liftRC (tryRemoteError parseRequest) >>= \case - Right (getNext, rc) -> processCommand getNext rc `catchAny` (reply . RRProtocolError . RPEException . tshow) + Right (getNext, rc) -> do + chatReadVar currentUser >>= \case + Nothing -> replyError $ ChatError CENoActiveUser + Just user -> processCommand user getNext rc `catchChatError` replyError Left e -> reply $ RRProtocolError e where parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand) parseRequest = do (header, getNext) <- parseHTTP2Body request reqBody (getNext,) <$> liftEitherWith (RPEInvalidJSON . T.pack) (J.eitherDecodeStrict' header) - processCommand :: GetChunk -> RemoteCommand -> m () - processCommand getNext = \case + replyError = reply . RRChatResponse . CRChatCmdError Nothing + processCommand :: User -> GetChunk -> RemoteCommand -> m () + processCommand user getNext = \case RCHello {deviceName = desktopName} -> handleHello desktopName >>= reply RCSend {command} -> handleSend execChatCommand command >>= reply RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply - RCStoreFile {fileSize, encrypt} -> handleStoreFile fileSize encrypt getNext >>= reply - RCGetFile {filePath} -> handleGetFile filePath replyWith + RCStoreFile {fileName, fileSize, fileDigest} -> handleStoreFile fileName fileSize fileDigest getNext >>= reply + RCGetFile {file} -> handleGetFile user file replyWith reply :: RemoteResponse -> m () reply = (`replyWith` \_ -> pure ()) replyWith :: Respond m @@ -258,7 +324,8 @@ handleHello :: ChatMonad m => Text -> m RemoteResponse handleHello desktopName = do logInfo $ "Hello from " <> tshow desktopName mobileName <- chatReadVar localDeviceName - pure RRHello {encoding = localEncoding, deviceName = mobileName} + encryptFiles <- chatReadVar encryptLocalFiles + pure RRHello {encoding = localEncoding, deviceName = mobileName, encryptFiles} handleSend :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteResponse handleSend execChatCommand command = do @@ -272,20 +339,36 @@ handleRecv time events = do logDebug $ "Recv: " <> tshow time RRChatEvent <$> (timeout time . atomically $ readTBQueue events) -handleStoreFile :: ChatMonad m => Word32 -> Maybe Bool -> GetChunk -> m RemoteResponse -handleStoreFile _fileSize _encrypt _getNext = error "TODO" <$ logError "TODO: handleStoreFile" +-- TODO this command could remember stored files and return IDs to allow removing files that are not needed. +-- Also, there should be some process removing unused files uploaded to remote host (possibly, all unused files). +handleStoreFile :: forall m. ChatMonad m => FilePath -> Word32 -> FileDigest -> GetChunk -> m RemoteResponse +handleStoreFile fileName fileSize fileDigest getChunk = + either RRProtocolError RRFileStored <$> (chatReadVar filesFolder >>= storeFile) + where + storeFile :: Maybe FilePath -> m (Either RemoteProtocolError FilePath) + storeFile = \case + Just ff -> takeFileName <$$> storeFileTo ff + Nothing -> storeFileTo =<< getDefaultFilesFolder + storeFileTo :: FilePath -> m (Either RemoteProtocolError FilePath) + storeFileTo dir = liftRC . tryRemoteError $ do + filePath <- dir `uniqueCombine` fileName + receiveRemoteFile getChunk fileSize fileDigest filePath + pure filePath -handleGetFile :: ChatMonad m => FilePath -> Respond m -> m () -handleGetFile path reply = do - logDebug $ "GetFile: " <> tshow path - withFile path ReadMode $ \h -> do - fileSize' <- hFileSize h - when (fileSize' > toInteger (maxBound :: Word32)) $ throwIO RPEFileTooLarge - let fileSize = fromInteger fileSize' - reply RRFile {fileSize} $ \send -> hSendFile h send fileSize +handleGetFile :: ChatMonad m => User -> RemoteFile -> Respond m -> m () +handleGetFile User {userId} RemoteFile{userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do + logDebug $ "GetFile: " <> tshow filePath + unless (userId == commandUserId) $ throwChatError $ CEDifferentActiveUser {commandUserId, activeUserId = userId} + path <- maybe filePath ( filePath) <$> chatReadVar filesFolder + withStore $ \db -> do + cf <- getLocalCryptoFile db commandUserId fileId sent + unless (cf == cf') $ throwError $ SEFileNotFound fileId + liftRC (tryRemoteError $ getFileInfo path) >>= \case + Left e -> reply (RRProtocolError e) $ \_ -> pure () + Right (fileSize, fileDigest) -> + withFile path ReadMode $ \h -> + reply RRFile {fileSize, fileDigest} $ \send -> hSendFile h send fileSize --- TODO the problem with this code was that it wasn't clear where the recursion can happen, --- by splitting receiving and processing to two functions it becomes clear discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> m () discoverRemoteCtrls discovered = Discovery.withListener $ receive >=> process where diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index aa4ebe5952..2deb177775 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -20,7 +20,7 @@ import Data.Aeson.TH (deriveJSON) import qualified Data.Aeson.Types as JT import Data.ByteString (ByteString) import Data.ByteString.Builder (Builder, word32BE, lazyByteString) -import qualified Data.ByteString.Lazy as BL +import qualified Data.ByteString.Lazy as LB import Data.String (fromString) import Data.Text (Text) import Data.Text.Encoding (decodeUtf8) @@ -28,34 +28,35 @@ import Data.Word (Word32) import qualified Network.HTTP.Types as N import qualified Network.HTTP2.Client as H import Network.Transport.Internal (decodeWord32) -import Simplex.Chat.Controller (ChatResponse) +import Simplex.Chat.Controller +import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types -import Simplex.Messaging.Crypto.File (CryptoFile) +import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) import Simplex.Messaging.Transport.Buffer (getBuffered) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) -import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile, hSendFile) -import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow, whenM) -import System.FilePath (()) +import Simplex.Messaging.Transport.HTTP2.File (hSendFile) +import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow) +import System.FilePath ((), takeFileName) import UnliftIO -import UnliftIO.Directory (doesFileExist, getFileSize) data RemoteCommand = RCHello {deviceName :: Text} | RCSend {command :: Text} -- TODO maybe ChatCommand here? | RCRecv {wait :: Int} -- this wait should be less than HTTP timeout | -- local file encryption is determined by the host, but can be overridden for videos - RCStoreFile {fileSize :: Word32, encrypt :: Maybe Bool} -- requires attachment - | RCGetFile {filePath :: FilePath} + RCStoreFile {fileName :: String, fileSize :: Word32, fileDigest :: FileDigest} -- requires attachment + | RCGetFile {file :: RemoteFile} deriving (Show) data RemoteResponse - = RRHello {encoding :: PlatformEncoding, deviceName :: Text} + = RRHello {encoding :: PlatformEncoding, deviceName :: Text, encryptFiles :: Bool} | RRChatResponse {chatResponse :: ChatResponse} | RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout - | RRFileStored {fileSource :: CryptoFile} - | RRFile {fileSize :: Word32} -- provides attachment + | RRFileStored {filePath :: String} + | RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest | RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- ^ The protocol error happened on the server side deriving (Show) @@ -67,14 +68,13 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) createRemoteHostClient :: HTTP2Client -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient createRemoteHostClient httpClient desktopName = do - logInfo "Sending initial hello" - (_getNext, rr) <- sendRemoteCommand httpClient localEncoding Nothing RCHello {deviceName = desktopName} - case rr of - rrh@RRHello {encoding, deviceName = mobileName} -> do - logInfo $ "Got initial hello: " <> tshow rrh + logDebug "Sending initial hello" + sendRemoteCommand' httpClient localEncoding Nothing RCHello {deviceName = desktopName} >>= \case + RRHello {encoding, deviceName = mobileName, encryptFiles} -> do + logDebug "Got initial hello" when (encoding == PEKotlin && localEncoding == PESwift) $ throwError RPEIncompatibleEncoding - pure RemoteHostClient {remoteEncoding = encoding, remoteDeviceName = mobileName, httpClient} - _ -> throwError $ RPEUnexpectedResponse $ tshow rr + pure RemoteHostClient {hostEncoding = encoding, hostDeviceName = mobileName, httpClient, encryptHostFiles = encryptFiles} + r -> badResponse r closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m () closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient @@ -82,48 +82,37 @@ closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client -- ** Commands remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse -remoteSend RemoteHostClient {httpClient, remoteEncoding} cmd = do - (_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCSend {command = decodeUtf8 cmd} - case rr of +remoteSend RemoteHostClient {httpClient, hostEncoding} cmd = + sendRemoteCommand' httpClient hostEncoding Nothing RCSend {command = decodeUtf8 cmd} >>= \case RRChatResponse cr -> pure cr - _ -> throwError $ RPEUnexpectedResponse $ tshow rr + r -> badResponse r remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) -remoteRecv RemoteHostClient {httpClient, remoteEncoding} ms = do - (_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCRecv {wait=ms} - case rr of +remoteRecv RemoteHostClient {httpClient, hostEncoding} ms = + sendRemoteCommand' httpClient hostEncoding Nothing RCRecv {wait = ms} >>= \case RRChatEvent cr_ -> pure cr_ - _ -> throwError $ RPEUnexpectedResponse $ tshow rr + r -> badResponse r -remoteStoreFile :: RemoteHostClient -> FilePath -> Maybe Bool -> ExceptT RemoteProtocolError IO CryptoFile -remoteStoreFile RemoteHostClient {httpClient, remoteEncoding} localPath encrypt = do - (_getNext, rr) <- withFile localPath ReadMode $ \h -> do - fileSize' <- hFileSize h - when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileTooLarge - let fileSize = fromInteger fileSize' - sendRemoteCommand httpClient remoteEncoding (Just (h, fileSize)) RCStoreFile {encrypt, fileSize} - case rr of - RRFileStored {fileSource} -> pure fileSource - _ -> throwError $ RPEUnexpectedResponse $ tshow rr +remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath +remoteStoreFile RemoteHostClient {httpClient, hostEncoding} localPath fileName = do + (fileSize, fileDigest) <- getFileInfo localPath + let send h = sendRemoteCommand' httpClient hostEncoding (Just (h, fileSize)) RCStoreFile {fileName, fileSize, fileDigest} + withFile localPath ReadMode send >>= \case + RRFileStored {filePath = filePath'} -> pure filePath' + r -> badResponse r --- TODO this should work differently for CLI and UI clients --- CLI - potentially, create new unique names and report them as created --- UI - always use the same names and report error if file already exists --- alternatively, CLI should also use a fixed folder for remote session --- Possibly, path in the database should be optional and CLI commands should allow configuring it per session or use temp or download folder -remoteGetFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath -remoteGetFile RemoteHostClient {httpClient, remoteEncoding} baseDir filePath = do - (getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCGetFile {filePath} - expectedSize <- case rr of - RRFile {fileSize} -> pure fileSize - _ -> throwError $ RPEUnexpectedResponse $ tshow rr - whenM (liftIO $ doesFileExist localFile) $ throwError RPEStoredFileExists - rc <- liftIO $ withFile localFile WriteMode $ \h -> hReceiveFile getNext h expectedSize - when (rc /= 0) $ throwError RPEInvalidSize - whenM ((== expectedSize) . fromIntegral <$> getFileSize localFile) $ throwError RPEInvalidSize - pure localFile - where - localFile = baseDir filePath +remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO () +remoteGetFile RemoteHostClient {httpClient, hostEncoding} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = + sendRemoteCommand httpClient hostEncoding Nothing RCGetFile {file = rf} >>= \case + (getChunk, RRFile {fileSize, fileDigest}) -> do + -- TODO we could optimize by checking size and hash before receiving the file + let localPath = destDir takeFileName filePath + receiveRemoteFile getChunk fileSize fileDigest localPath + (_, r) -> badResponse r + +-- TODO validate there is no attachment +sendRemoteCommand' :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO RemoteResponse +sendRemoteCommand' http remoteEncoding attachment_ rc = snd <$> sendRemoteCommand http remoteEncoding attachment_ rc sendRemoteCommand :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) sendRemoteCommand http remoteEncoding attachment_ rc = do @@ -139,6 +128,12 @@ sendRemoteCommand http remoteEncoding attachment_ rc = do Just (h, sz) -> hSendFile h send sz flush +badResponse :: RemoteResponse -> ExceptT RemoteProtocolError IO a +badResponse = \case + RRProtocolError e -> throwError e + -- TODO handle chat errors? + r -> throwError $ RPEUnexpectedResponse $ tshow r + -- * Transport-level wrappers convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value @@ -183,7 +178,7 @@ pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) -- | Convert a command or a response into 'Builder'. sizePrefixedEncode :: J.ToJSON a => a -> Builder -sizePrefixedEncode value = word32BE (fromIntegral $ BL.length json) <> lazyByteString json +sizePrefixedEncode value = word32BE (fromIntegral $ LB.length json) <> lazyByteString json where json = J.encode value diff --git a/src/Simplex/Chat/Remote/Transport.hs b/src/Simplex/Chat/Remote/Transport.hs new file mode 100644 index 0000000000..bf798444c0 --- /dev/null +++ b/src/Simplex/Chat/Remote/Transport.hs @@ -0,0 +1,27 @@ +module Simplex.Chat.Remote.Transport where + +import Control.Monad +import Control.Monad.Except +import Data.ByteString (ByteString) +import qualified Data.ByteString.Lazy as LB +import Data.Word (Word32) +import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.Chat.Remote.Types +import qualified Simplex.Messaging.Crypto.Lazy as LC +import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile) +import UnliftIO +import UnliftIO.Directory (getFileSize) + +receiveRemoteFile :: (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO () +receiveRemoteFile getChunk fileSize fileDigest toPath = do + diff <- liftIO $ withFile toPath WriteMode $ \h -> hReceiveFile getChunk h fileSize + unless (diff == 0) $ throwError RPEFileSize + digest <- liftIO $ LC.sha512Hash <$> LB.readFile toPath + unless (FileDigest digest == fileDigest) $ throwError RPEFileDigest + +getFileInfo :: FilePath -> ExceptT RemoteProtocolError IO (Word32, FileDigest) +getFileInfo filePath = do + fileDigest <- liftIO $ FileDigest . LC.sha512Hash <$> LB.readFile filePath + fileSize' <- getFileSize filePath + when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileSize + pure (fromInteger fileSize', fileDigest) diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index d16955199e..6611d04471 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -10,14 +10,16 @@ import qualified Data.Aeson.TH as J import Data.Int (Int64) import Data.Text (Text) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import UnliftIO data RemoteHostClient = RemoteHostClient - { remoteEncoding :: PlatformEncoding, - remoteDeviceName :: Text, - httpClient :: HTTP2Client + { hostEncoding :: PlatformEncoding, + hostDeviceName :: Text, + httpClient :: HTTP2Client, + encryptHostFiles :: Bool } data RemoteHostSession = RemoteHostSession @@ -32,7 +34,8 @@ data RemoteProtocolError | RPEIncompatibleEncoding | RPEUnexpectedFile | RPENoFile - | RPEFileTooLarge + | RPEFileSize + | RPEFileDigest | RPEUnexpectedResponse {response :: Text} -- ^ Wrong response received for the command sent | RPEStoredFileExists -- ^ A file already exists in the destination position | RPEHTTP2 {http2Error :: Text} @@ -87,7 +90,14 @@ data RemoteCtrlInfo = RemoteCtrlInfo } deriving (Show) --- TODO: put into a proper place +data RemoteFile = RemoteFile + { userId :: Int64, + fileId :: Int64, + sent :: Bool, + fileSource :: CryptoFile + } + deriving (Show) + data PlatformEncoding = PESwift | PEKotlin @@ -122,3 +132,5 @@ $(J.deriveJSON defaultJSON ''RemoteHostInfo) $(J.deriveJSON defaultJSON ''RemoteCtrl) $(J.deriveJSON defaultJSON ''RemoteCtrlInfo) + +$(J.deriveJSON defaultJSON ''RemoteFile) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index a710696dad..95e586919d 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -2,6 +2,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -71,6 +72,7 @@ module Simplex.Chat.Store.Files getSndFileTransfer, getSndFileTransfers, getContactFileInfo, + getLocalCryptoFile, updateDirectCIFileStatus, ) where @@ -602,7 +604,10 @@ getRcvFileTransferById db fileId = do (user,) <$> getRcvFileTransfer db user fileId getRcvFileTransfer :: DB.Connection -> User -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer -getRcvFileTransfer db User {userId} fileId = do +getRcvFileTransfer db User {userId} = getRcvFileTransfer_ db userId + +getRcvFileTransfer_ :: DB.Connection -> UserId -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer +getRcvFileTransfer_ db userId fileId = do rftRow <- ExceptT . firstRow id (SERcvFileNotFound fileId) $ DB.query @@ -808,25 +813,26 @@ getFileTransferProgress db user fileId = do getFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransfer getFileTransfer db user@User {userId} fileId = - fileTransfer =<< liftIO getFileTransferRow + fileTransfer =<< liftIO (getFileTransferRow_ db userId fileId) where fileTransfer :: [(Maybe Int64, Maybe Int64)] -> ExceptT StoreError IO FileTransfer fileTransfer [(Nothing, Just _)] = FTRcv <$> getRcvFileTransfer db user fileId fileTransfer _ = do (ftm, fts) <- getSndFileTransfer db user fileId pure $ FTSnd {fileTransferMeta = ftm, sndFileTransfers = fts} - getFileTransferRow :: IO [(Maybe Int64, Maybe Int64)] - getFileTransferRow = - DB.query - db - [sql| - SELECT s.file_id, r.file_id - FROM files f - LEFT JOIN snd_files s ON s.file_id = f.file_id - LEFT JOIN rcv_files r ON r.file_id = f.file_id - WHERE user_id = ? AND f.file_id = ? - |] - (userId, fileId) + +getFileTransferRow_ :: DB.Connection -> UserId -> Int64 -> IO [(Maybe Int64, Maybe Int64)] +getFileTransferRow_ db userId fileId = + DB.query + db + [sql| + SELECT s.file_id, r.file_id + FROM files f + LEFT JOIN snd_files s ON s.file_id = f.file_id + LEFT JOIN rcv_files r ON r.file_id = f.file_id + WHERE user_id = ? AND f.file_id = ? + |] + (userId, fileId) getSndFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (FileTransferMeta, [SndFileTransfer]) getSndFileTransfer db user fileId = do @@ -861,7 +867,10 @@ getSndFileTransfers_ db userId fileId = Nothing -> Left $ SESndFileInvalid fileId getFileTransferMeta :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransferMeta -getFileTransferMeta db User {userId} fileId = +getFileTransferMeta db User {userId} = getFileTransferMeta_ db userId + +getFileTransferMeta_ :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO FileTransferMeta +getFileTransferMeta_ db userId fileId = ExceptT . firstRow fileTransferMeta (SEFileNotFound fileId) $ DB.query db @@ -883,6 +892,20 @@ getContactFileInfo db User {userId} Contact {contactId} = map toFileInfo <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.contact_id = ?") (userId, contactId) +getLocalCryptoFile :: DB.Connection -> UserId -> Int64 -> Bool -> ExceptT StoreError IO CryptoFile +getLocalCryptoFile db userId fileId sent = + liftIO (getFileTransferRow_ db userId fileId) >>= \case + [(Nothing, Just _)] -> do + when sent $ throwError $ SEFileNotFound fileId + RcvFileTransfer {fileStatus, cryptoArgs} <- getRcvFileTransfer_ db userId fileId + case fileStatus of + RFSComplete RcvFileInfo {filePath} -> pure $ CryptoFile filePath cryptoArgs + _ -> throwError $ SEFileNotFound fileId + _ -> do + unless sent $ throwError $ SEFileNotFound fileId + FileTransferMeta {filePath, xftpSndFile} <- getFileTransferMeta_ db userId fileId + pure $ CryptoFile filePath $ xftpSndFile >>= \f -> f.cryptoArgs + updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem updateDirectCIFileStatus db user fileId fileStatus = do aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db user fileId diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2a3b74da37..9ae00159b7 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -14,6 +14,7 @@ module Simplex.Chat.View where import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) import Data.Function (on) @@ -76,7 +77,7 @@ serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> Time serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] -responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case +responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] @@ -185,10 +186,10 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile - CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci - CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' testView "completed" ci + CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci + CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRRcvFileError u ci e -> ttyUser u $ receivingFile_' testView "error" ci <> [sShow e] + CRRcvFileError u ci e -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e] CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndFileStartXFTP {} -> [] @@ -272,6 +273,9 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei CRRemoteHostList hs -> viewRemoteHosts hs CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] + CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> + [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] + <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> ["remote controller " <> sShow rcId <> " registered"] CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] @@ -1493,18 +1497,25 @@ savingFile' (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource ["saving file " <> sShow fileId <> fileFrom chat chatDir <> " to " <> plain filePath] savingFile' _ = ["saving file"] -- shouldn't happen -receivingFile_' :: Bool -> String -> AChatItem -> [StyledString] -receivingFile_' testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just (CryptoFile _ cfArgs_)}, chatDir}) = - [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ +receivingFile_' :: (Maybe RemoteHostId, Maybe User) -> Bool -> String -> AChatItem -> [StyledString] +receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just f@(CryptoFile _ cfArgs_)}, chatDir}) = + [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ <> getRemoteFileStr where - cfArgsStr (Just cfArgs@(CFArgs key nonce)) = [plain s | status == "completed"] - where - s = - if testView - then LB.toStrict $ J.encode cfArgs - else "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce + cfArgsStr (Just cfArgs) = [plain (cryptoFileArgsStr testView cfArgs) | status == "completed"] cfArgsStr _ = [] -receivingFile_' _ status _ = [plain status <> " receiving file"] -- shouldn't happen + getRemoteFileStr = case hu of + (Just rhId, Just User {userId}) | status == "completed" -> + [ "File received to connected remote host " <> sShow rhId, + "To download to this device use:", + highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f})) + ] + _ -> [] +receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen + +cryptoFileArgsStr :: Bool -> CryptoFileArgs -> ByteString +cryptoFileArgsStr testView cfArgs@(CFArgs key nonce) + | testView = LB.toStrict $ J.encode cfArgs + | otherwise = "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce fileFrom :: ChatInfo c -> CIDirection c d -> StyledString fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct @@ -1818,8 +1829,8 @@ viewChatError logLevel = \case Nothing -> "" cId :: Connection -> StyledString cId conn = sShow conn.connId - ChatErrorRemoteCtrl todo'rc -> [sShow todo'rc] - ChatErrorRemoteHost remoteHostId todo'rh -> [sShow remoteHostId, sShow todo'rh] + ChatErrorRemoteCtrl e -> [plain $ "remote controller error: " <> show e] + ChatErrorRemoteHost rhId e -> [plain $ "remote host " <> show rhId <> " error: " <> show e] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] sqliteError' = \case diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 452f9ca21d..be1d3c1a2e 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -9,7 +9,9 @@ import ChatClient import ChatTests.Utils import Control.Logger.Simple import Control.Monad +import qualified Data.Aeson as J import qualified Data.ByteString as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Network.HTTP.Types (ok200) @@ -17,10 +19,14 @@ import qualified Network.HTTP2.Client as C import qualified Network.HTTP2.Server as S import qualified Network.Socket as N import qualified Network.TLS as TLS +import Simplex.Chat.Archive (archiveFilesFolder) +import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) import qualified Simplex.Chat.Controller as Controller +import Simplex.Chat.Mobile.File import Simplex.Chat.Remote.Types import qualified Simplex.Chat.Remote.Discovery as Discovery import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..)) @@ -28,7 +34,7 @@ import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util -import System.FilePath (makeRelative, ()) +import System.FilePath (()) import Test.Hspec import UnliftIO import UnliftIO.Concurrent @@ -41,7 +47,9 @@ remoteTests = describe "Remote" $ do it "performs protocol handshake" remoteHandshakeTest it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check it "sends messages" remoteMessageTest - xit "sends files" remoteFileTest + describe "remote files" $ do + it "store/get/send/receive files" remoteStoreFileTest + it "should sends files from CLI wihtout /store" remoteCLIFileTest -- * Low-level TLS with ephemeral credentials @@ -159,32 +167,158 @@ remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob threadDelay 1000000 logNote "done" -remoteFileTest :: (HasCallStack) => FilePath -> IO () -remoteFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do +remoteStoreFileTest :: HasCallStack => FilePath -> IO () +remoteStoreFileTest = + testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> + withXFTPServer $ do + let mobileFiles = "./tests/tmp/mobile_files" + mobile ##> ("/_files_folder " <> mobileFiles) + mobile <## "ok" + let desktopFiles = "./tests/tmp/desktop_files" + desktop ##> ("/_files_folder " <> desktopFiles) + desktop <## "ok" + let desktopHostFiles = "./tests/tmp/remote_hosts_data" + desktop ##> ("/remote_hosts_folder " <> desktopHostFiles) + desktop <## "ok" + let bobFiles = "./tests/tmp/bob_files" + bob ##> ("/_files_folder " <> bobFiles) + bob <## "ok" + startRemote mobile desktop + contactBob desktop bob + rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) + desktopHostStore <- case M.lookup 1 rhs of + Just RemoteHostSession {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder + _ -> fail "Host session 1 should be started" + desktop ##> "/store remote file 1 tests/fixtures/test.pdf" + desktop <## "file test.pdf stored on remote host 1" + src <- B.readFile "tests/fixtures/test.pdf" + B.readFile (mobileFiles "test.pdf") `shouldReturn` src + B.readFile (desktopHostStore "test.pdf") `shouldReturn` src + desktop ##> "/store remote file 1 tests/fixtures/test.pdf" + desktop <## "file test_1.pdf stored on remote host 1" + B.readFile (mobileFiles "test_1.pdf") `shouldReturn` src + B.readFile (desktopHostStore "test_1.pdf") `shouldReturn` src + desktop ##> "/store remote file 1 encrypt=on tests/fixtures/test.pdf" + desktop <## "file test_2.pdf stored on remote host 1" + Just cfArgs@(CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine desktop + chatReadFile (mobileFiles "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src) + chatReadFile (desktopHostStore "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src) + + removeFile (desktopHostStore "test_1.pdf") + removeFile (desktopHostStore "test_2.pdf") + + -- cannot get file before it is used + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + hostError desktop "SEFileNotFound" + -- send file not encrypted locally on mobile host + desktop ##> "/_send @2 json {\"filePath\": \"test_1.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"sending a file\"}}" + desktop <# "@bob sending a file" + desktop <# "/f @bob test_1.pdf" + desktop <## "use /fc 1 to cancel sending" + bob <# "alice> sending a file" + bob <# "alice> sends file test_1.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [
/ | ] to receive it" + bob ##> "/fr 1" + concurrentlyN_ + [ do + desktop <## "completed uploading file 1 (test_1.pdf) for bob", + do + bob <## "saving file 1 from alice to test_1.pdf" + bob <## "started receiving file 1 (test_1.pdf) from alice" + bob <## "completed receiving file 1 (test_1.pdf) from alice" + ] + B.readFile (bobFiles "test_1.pdf") `shouldReturn` src + -- returns error for inactive user + desktop ##> "/get remote file 1 {\"userId\": 2, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + hostError desktop "CEDifferentActiveUser" + -- returns error with incorrect file ID + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 2, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + hostError desktop "SEFileNotFound" + -- gets file + doesFileExist (desktopHostStore "test_1.pdf") `shouldReturn` False + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + desktop <## "ok" + B.readFile (desktopHostStore "test_1.pdf") `shouldReturn` src + + -- send file encrypted locally on mobile host + desktop ##> ("/_send @2 json {\"fileSource\": {\"filePath\":\"test_2.pdf\", \"cryptoArgs\": " <> LB.unpack (J.encode cfArgs) <> "}, \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}") + desktop <# "/f @bob test_2.pdf" + desktop <## "use /fc 2 to cancel sending" + bob <# "alice> sends file test_2.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 2 [/ | ] to receive it" + bob ##> "/fr 2" + concurrentlyN_ + [ do + desktop <## "completed uploading file 2 (test_2.pdf) for bob", + do + bob <## "saving file 2 from alice to test_2.pdf" + bob <## "started receiving file 2 (test_2.pdf) from alice" + bob <## "completed receiving file 2 (test_2.pdf) from alice" + ] + B.readFile (bobFiles "test_2.pdf") `shouldReturn` src + + -- receive file via remote host + copyFile "./tests/fixtures/test.jpg" (bobFiles "test.jpg") + bob #> "/f @alice test.jpg" + bob <## "use /fc 3 to cancel sending" + desktop <# "bob> sends file test.jpg (136.5 KiB / 139737 bytes)" + desktop <## "use /fr 3 [/ | ] to receive it" + desktop ##> "/fr 3 encrypt=on" + concurrentlyN_ + [ do + bob <## "completed uploading file 3 (test.jpg) for alice", + do + desktop <## "saving file 3 from bob to test.jpg" + desktop <## "started receiving file 3 (test.jpg) from bob" + desktop <## "completed receiving file 3 (test.jpg) from bob" + ] + Just cfArgs'@(CFArgs key' nonce') <- J.decode . LB.pack <$> getTermLine desktop + desktop <## "File received to connected remote host 1" + desktop <## "To download to this device use:" + getCmd <- getTermLine desktop + getCmd `shouldBe` ("/get remote file 1 {\"userId\":1,\"fileId\":3,\"sent\":false,\"fileSource\":{\"filePath\":\"test.jpg\",\"cryptoArgs\":" <> LB.unpack (J.encode cfArgs') <> "}}") + src' <- B.readFile (bobFiles "test.jpg") + chatReadFile (mobileFiles "test.jpg") (strEncode key') (strEncode nonce') `shouldReturn` Right (LB.fromStrict src') + doesFileExist (desktopHostStore "test.jpg") `shouldReturn` False + -- returns error with incorrect key + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 3, \"sent\": false, \"fileSource\": {\"filePath\": \"test.jpg\", \"cryptoArgs\": null}}" + hostError desktop "SEFileNotFound" + doesFileExist (desktopHostStore "test.jpg") `shouldReturn` False + desktop ##> getCmd + desktop <## "ok" + chatReadFile (desktopHostStore "test.jpg") (strEncode key') (strEncode nonce') `shouldReturn` Right (LB.fromStrict src') + + stopMobile mobile desktop + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} + hostError cc err = do + r <- getTermLine cc + r `shouldStartWith` "remote host 1 error" + r `shouldContain` err + +remoteCLIFileTest :: (HasCallStack) => FilePath -> IO () +remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do + createDirectoryIfMissing True "./tests/tmp/tmp/" let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) mobile <## "ok" - let desktopFiles = "./tests/tmp/desktop_files" - desktop ##> ("/_files_folder " <> desktopFiles) + let bobFiles = "./tests/tmp/bob_files/" + createDirectoryIfMissing True bobFiles + let desktopHostFiles = "./tests/tmp/remote_hosts_data" + desktop ##> ("/remote_hosts_folder " <> desktopHostFiles) desktop <## "ok" - let bobFiles = "./tests/tmp/bob_files" - bob ##> ("/_files_folder " <> bobFiles) - bob <## "ok" startRemote mobile desktop contactBob desktop bob rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) - desktopStore <- case M.lookup 1 rhs of - Just RemoteHostSession {storePath} -> pure storePath + desktopHostStore <- case M.lookup 1 rhs of + Just RemoteHostSession {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder _ -> fail "Host session 1 should be started" - doesFileExist "./tests/tmp/mobile_files/test.pdf" `shouldReturn` False - doesFileExist (desktopFiles desktopStore "test.pdf") `shouldReturn` False mobileName <- userName mobile - bobsFile <- makeRelative bobFiles <$> makeAbsolute "tests/fixtures/test.pdf" - bob #> ("/f @" <> mobileName <> " " <> bobsFile) + bob #> ("/f @" <> mobileName <> " " <> "tests/fixtures/test.pdf") bob <## "use /fc 1 to cancel sending" desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" @@ -192,63 +326,47 @@ remoteFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop ##> "/fr 1" concurrentlyN_ [ do - bob <## "started sending file 1 (test.pdf) to alice" - bob <## "completed sending file 1 (test.pdf) to alice", + bob <## "completed uploading file 1 (test.pdf) for alice", do desktop <## "saving file 1 from bob to test.pdf" desktop <## "started receiving file 1 (test.pdf) from bob" + desktop <## "completed receiving file 1 (test.pdf) from bob" ] - let desktopReceived = desktopFiles desktopStore "test.pdf" - -- desktop <## ("completed receiving file 1 (" <> desktopReceived <> ") from bob") - desktop <## "completed receiving file 1 (test.pdf) from bob" - bobsFileSize <- getFileSize bobsFile - -- getFileSize desktopReceived `shouldReturn` bobsFileSize - bobsFileBytes <- B.readFile bobsFile - -- B.readFile desktopReceived `shouldReturn` bobsFileBytes - -- test file transit on mobile - mobile ##> "/fs 1" - mobile <## "receiving file 1 (test.pdf) complete, path: test.pdf" - getFileSize (mobileFiles "test.pdf") `shouldReturn` bobsFileSize - B.readFile (mobileFiles "test.pdf") `shouldReturn` bobsFileBytes + desktop <## "File received to connected remote host 1" + desktop <## "To download to this device use:" + getCmd <- getTermLine desktop + src <- B.readFile "tests/fixtures/test.pdf" + B.readFile (mobileFiles "test.pdf") `shouldReturn` src + doesFileExist (desktopHostStore "test.pdf") `shouldReturn` False + desktop ##> getCmd + desktop <## "ok" + B.readFile (desktopHostStore "test.pdf") `shouldReturn` src - logNote "file received" - - desktopFile <- makeRelative desktopFiles <$> makeAbsolute "tests/fixtures/logo.jpg" -- XXX: not necessary for _send, but required for /f - logNote $ "sending " <> tshow desktopFile - doesFileExist (bobFiles "logo.jpg") `shouldReturn` False - doesFileExist (mobileFiles "logo.jpg") `shouldReturn` False - desktop ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/logo.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" - desktop <# "@bob hi, sending a file" - desktop <# "/f @bob logo.jpg" + desktop `send` "/f @bob tests/fixtures/test.jpg" + desktop <# "/f @bob test.jpg" desktop <## "use /fc 2 to cancel sending" - bob <# "alice> hi, sending a file" - bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 2 [/ | ] to receive it" - bob ##> "/fr 2" + bob ##> ("/fr 2 " <> bobFiles) concurrentlyN_ [ do - bob <## "saving file 2 from alice to logo.jpg" - bob <## "started receiving file 2 (logo.jpg) from alice" - bob <## "completed receiving file 2 (logo.jpg) from alice" - bob ##> "/fs 2" - bob <## "receiving file 2 (logo.jpg) complete, path: logo.jpg", + desktop <## "completed uploading file 2 (test.jpg) for bob", do - desktop <## "started sending file 2 (logo.jpg) to bob" - desktop <## "completed sending file 2 (logo.jpg) to bob" + bob <## "saving file 2 from alice to ./tests/tmp/bob_files/test.jpg" + bob <## "started receiving file 2 (test.jpg) from alice" + bob <## "completed receiving file 2 (test.jpg) from alice" ] - desktopFileSize <- getFileSize desktopFile - getFileSize (bobFiles "logo.jpg") `shouldReturn` desktopFileSize - getFileSize (mobileFiles "logo.jpg") `shouldReturn` desktopFileSize - desktopFileBytes <- B.readFile desktopFile - B.readFile (bobFiles "logo.jpg") `shouldReturn` desktopFileBytes - B.readFile (mobileFiles "logo.jpg") `shouldReturn` desktopFileBytes - - logNote "file sent" + src' <- B.readFile "tests/fixtures/test.jpg" + B.readFile (mobileFiles "test.jpg") `shouldReturn` src' + B.readFile (desktopHostStore "test.jpg") `shouldReturn` src' + B.readFile (bobFiles "test.jpg") `shouldReturn` src' stopMobile mobile desktop + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} -- * Utils From be44632b0bc71df56d034d16ffdce0b41dedd171 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 30 Oct 2023 16:00:54 +0200 Subject: [PATCH 126/294] implement some of the robust discovery rfc (#3283) * implement robust discovery * remove qualified --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 6 +- src/Simplex/Chat/Controller.hs | 11 +- src/Simplex/Chat/Remote.hs | 88 ++++++------ src/Simplex/Chat/Remote/Discovery.hs | 196 +++++++++++++++++--------- src/Simplex/Chat/Remote/Multicast.hsc | 9 +- src/Simplex/Chat/Remote/Protocol.hs | 4 +- src/Simplex/Chat/Remote/Types.hs | 181 +++++++++++++++++++++++- src/Simplex/Chat/Store/Remote.hs | 10 +- src/Simplex/Chat/View.hs | 9 +- tests/RemoteTests.hs | 66 ++++++--- 10 files changed, 430 insertions(+), 150 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e46a426d8c..383e045f8b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -213,6 +213,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty localDeviceName <- newTVarIO "" -- TODO set in config + multicastSubscribers <- newTMVarIO 0 remoteHostSessions <- atomically TM.empty remoteHostsFolder <- newTVarIO Nothing remoteCtrlSession <- newTVarIO Nothing @@ -247,6 +248,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen rcvFiles, currentCalls, localDeviceName, + multicastSubscribers, remoteHostSessions, remoteHostsFolder, remoteCtrlSession, @@ -5861,8 +5863,8 @@ chatCommandP = "/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath), "/get remote file " *> (GetRemoteFile <$> A.decimal <* A.space <*> jsonP), "/start remote ctrl" $> StartRemoteCtrl, - "/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP <* A.space <*> textP)), - "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), + "/register remote ctrl " *> (RegisterRemoteCtrl <$> strP), + -- "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), "/list remote ctrls" $> ListRemoteCtrls, "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bc4cfaaf89..19e8dc34dc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -25,7 +25,7 @@ import Control.Monad.Reader import Crypto.Random (ChaChaDRG) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?)) import qualified Data.Aeson as J -import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) @@ -40,6 +40,7 @@ import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) +import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -177,6 +178,7 @@ data ChatController = ChatController rcvFiles :: TVar (Map Int64 Handle), currentCalls :: TMap ContactId Call, localDeviceName :: TVar Text, + multicastSubscribers :: TMVar Int, remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts remoteHostsFolder :: TVar (Maybe FilePath), -- folder for remote hosts data remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers @@ -424,12 +426,12 @@ data ChatCommand | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers - | RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake + | RegisterRemoteCtrl SignedOOB -- ^ Register OOB data for remote controller discovery and handshake | ListRemoteCtrls | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data | StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session - | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a satellite session + | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a remote controller session | QuitChat | ShowVersion | DebugLocks @@ -634,6 +636,7 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} + | CRRemoteHostStarted {remoteHost :: RemoteHostInfo, sessionOOB :: Text} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} @@ -1069,7 +1072,7 @@ data RemoteCtrlSession = RemoteCtrlSession discoverer :: Async (), supervisor :: Async (), hostServer :: Maybe (Async ()), - discovered :: TMap C.KeyHash TransportHost, + discovered :: TMap C.KeyHash (TransportHost, Word16), accepted :: TMVar RemoteCtrlId, remoteOutputQ :: TBQueue ChatResponse } diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 5344c4bea6..d6ccd25961 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Remote where +import Control.Applicative ((<|>)) import Control.Logger.Simple import Control.Monad import Control.Monad.Except @@ -24,15 +25,16 @@ import Data.ByteString (ByteString) import qualified Data.ByteString.Base64.URL as B64U import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B +import Data.Functor (($>)) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) -import Data.Word (Word32) -import Network.HTTP2.Server (responseStreaming) +import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N +import Network.HTTP2.Server (responseStreaming) import Network.Socket (SockAddr (..), hostAddressToTuple) import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller @@ -51,18 +53,17 @@ import Simplex.FileTransfer.Description (FileDigest (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Encoding (smpDecode) import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) -import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=), (<$$>)) -import System.FilePath ((), takeFileName) +import System.FilePath (takeFileName, ()) import UnliftIO import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) -import Data.Functor (($>)) -import Control.Applicative ((<|>)) -- * Desktop side @@ -108,11 +109,14 @@ startRemoteHost rhId = do toView (CRRemoteHostStopped rhId) -- only signal "stopped" when the session is unregistered cleanly -- block until some client is connected or an error happens logInfo $ "Remote host session connecting for " <> tshow rhId - httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ Discovery.announceRevHTTP2 tasks fingerprint credentials cleanupIO - logInfo $ "Remote host session connected for " <> tshow rhId rcName <- chatReadVar localDeviceName + localAddr <- asks multicastSubscribers >>= Discovery.getLocalAddress >>= maybe (throwError . ChatError $ CEInternalError "unable to get local address") pure + (dhKey, sigKey, ann, oob) <- Discovery.startSession (if rcName == "" then Nothing else Just rcName) (localAddr, read Discovery.DISCOVERY_PORT) fingerprint + toView CRRemoteHostStarted {remoteHost = remoteHostInfo rh True, sessionOOB = decodeUtf8 $ strEncode oob} + httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ Discovery.announceRevHTTP2 tasks (sigKey, ann) credentials cleanupIO + logInfo $ "Remote host session connected for " <> tshow rhId -- test connection and establish a protocol layer - remoteHostClient <- liftRH rhId $ createRemoteHostClient httpClient rcName + remoteHostClient <- liftRH rhId $ createRemoteHostClient httpClient dhKey rcName -- set up message polling oq <- asks outputQ asyncRegistered tasks . forever $ do @@ -125,7 +129,6 @@ startRemoteHost rhId = do { remoteHostId = rhId, storePath = storePath, displayName = hostDeviceName remoteHostClient, - remoteCtrlOOB = RemoteCtrlOOB {fingerprint, displayName=rcName}, sessionActive = True } @@ -159,10 +162,9 @@ createRemoteHost = do ((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) "Host" storePath <- liftIO randomStorePath let remoteName = "" -- will be passed from remote host in hello - remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath remoteName caKey caCert - localName <- chatReadVar localDeviceName - let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = localName} - pure RemoteHostInfo {remoteHostId, storePath, displayName = remoteName, remoteCtrlOOB, sessionActive = False} + rhId <- withStore' $ \db -> insertRemoteHost db storePath remoteName caKey caCert + rh <- withStore $ \db -> getRemoteHost db rhId + pure $ remoteHostInfo rh False -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -171,16 +173,14 @@ randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 listRemoteHosts :: ChatMonad m => m [RemoteHostInfo] listRemoteHosts = do active <- chatReadVar remoteHostSessions - rcName <- chatReadVar localDeviceName - map (rhInfo active rcName) <$> withStore' getRemoteHosts + map (rhInfo active) <$> withStore' getRemoteHosts where - rhInfo active rcName rh@RemoteHost {remoteHostId} = - remoteHostInfo rh (M.member remoteHostId active) rcName + rhInfo active rh@RemoteHost {remoteHostId} = + remoteHostInfo rh (M.member remoteHostId active) -remoteHostInfo :: RemoteHost -> Bool -> Text -> RemoteHostInfo -remoteHostInfo RemoteHost {remoteHostId, storePath, displayName, caCert} sessionActive rcName = - let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName} - in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive} +remoteHostInfo :: RemoteHost -> Bool -> RemoteHostInfo +remoteHostInfo RemoteHost {remoteHostId, storePath, displayName} sessionActive = + RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive} deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do @@ -231,7 +231,7 @@ getRemoteFile rhId rf = do processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ChatCommand -> ByteString -> m ChatResponse processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} cmd s = case cmd of SendFile chatName f -> sendFile "/f" chatName f - SendImage chatName f -> sendFile "/img" chatName f + SendImage chatName f -> sendFile "/img" chatName f _ -> liftRH remoteHostId $ remoteSend rhc s where sendFile cmdName chatName (CryptoFile path cfArgs) = do @@ -262,14 +262,14 @@ startRemoteCtrl execChatCommand = do chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} -- | Track remote host lifecycle in controller session state and signal UI on its progress -runHost :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> TMVar RemoteCtrlId -> (HTTP2Request -> m ()) -> m () +runHost :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> TMVar RemoteCtrlId -> (HTTP2Request -> m ()) -> m () runHost discovered accepted handleHttp = do remoteCtrlId <- atomically (readTMVar accepted) -- wait for ??? rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) - source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint + serviceAddress <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 source fingerprint handleHttp -- spawn server for remote protocol commands + server <- async $ Discovery.connectRevHTTP2 serviceAddress fingerprint handleHttp -- spawn server for remote protocol commands chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True _ <- waitCatch server -- wait for the server to finish @@ -369,34 +369,38 @@ handleGetFile User {userId} RemoteFile{userId = commandUserId, fileId, sent, fil withFile path ReadMode $ \h -> reply RRFile {fileSize, fileDigest} $ \send -> hSendFile h send fileSize -discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> m () -discoverRemoteCtrls discovered = Discovery.withListener $ receive >=> process +discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> m () +discoverRemoteCtrls discovered = do + subscribers <- asks multicastSubscribers + Discovery.withListener subscribers run where - -- TODO how would it receive more than one fingerprint? + run sock = receive sock >>= process sock + receive sock = Discovery.recvAnnounce sock >>= \case - (SockAddrInet _sockPort sockAddr, invite) -> case strDecode invite of - -- TODO it is probably better to report errors to view here - Left _ -> receive sock - Right fingerprint -> pure (sockAddr, fingerprint) + (SockAddrInet _sockPort sockAddr, sigAnnBytes) -> case smpDecode sigAnnBytes of + Right (SignedAnnounce ann _sig) -> pure (sockAddr, ann) + Left _ -> receive sock -- TODO it is probably better to report errors to view here _nonV4 -> receive sock - process (sockAddr, fingerprint) = do + + process sock (sockAddr, Announce {caFingerprint, serviceAddress=(annAddr, port)}) = do + unless (annAddr == sockAddr) $ logError "Announced address doesn't match socket address" let addr = THIPv4 (hostAddressToTuple sockAddr) ifM - (atomically $ TM.member fingerprint discovered) - (logDebug $ "Fingerprint already known: " <> tshow (addr, fingerprint)) + (atomically $ TM.member caFingerprint discovered) + (logDebug $ "Fingerprint already known: " <> tshow (addr, caFingerprint)) ( do - logInfo $ "New fingerprint announced: " <> tshow (addr, fingerprint) - atomically $ TM.insert fingerprint addr discovered + logInfo $ "New fingerprint announced: " <> tshow (addr, caFingerprint) + atomically $ TM.insert caFingerprint (addr, port) discovered ) -- TODO we check fingerprint for duplicate where id doesn't matter - to prevent re-insert - and don't check to prevent duplicate events, -- so UI now will have to check for duplicates again - withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case - Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required + withStore' (`getRemoteCtrlByFingerprint` caFingerprint) >>= \case + Nothing -> toView $ CRRemoteCtrlAnnounce caFingerprint -- unknown controller, ui "register" action required -- TODO Maybe Bool is very confusing - the intent is very unclear here Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required - Just False -> pure () -- skipping a rejected item + Just False -> run sock -- restart, skipping a rejected item Just True -> chatReadVar remoteCtrlSession >>= \case Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs index babc65e6a8..1ede108b07 100644 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ b/src/Simplex/Chat/Remote/Discovery.hs @@ -1,37 +1,33 @@ +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} -module Simplex.Chat.Remote.Discovery - ( -- * Announce - announceRevHTTP2, - runAnnouncer, - startTLSServer, - runHTTP2Client, - - -- * Discovery - connectRevHTTP2, - withListener, - openListener, - recvAnnounce, - connectTLSClient, - attachHTTP2Server, - ) -where +module Simplex.Chat.Remote.Discovery where import Control.Logger.Simple import Control.Monad +import Crypto.Random (getRandomBytes) import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64.URL as B64U import Data.Default (def) import Data.String (IsString) +import Data.Text (Text) +import Data.Text.Encoding (decodeUtf8) +import Data.Time.Clock.System (getSystemTime) +import Data.Word (Word16) import qualified Network.Socket as N import qualified Network.TLS as TLS import qualified Network.UDP as UDP import Simplex.Chat.Remote.Multicast (setMembership) -import Simplex.Chat.Remote.Types (Tasks, registerAsync) +import Simplex.Chat.Remote.Types import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding (Encoding (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Transport (supportedParameters) import qualified Simplex.Messaging.Transport as Transport @@ -39,8 +35,9 @@ import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportC import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) -import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServer) -import Simplex.Messaging.Util (ifM, tshow, whenM) +import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServerSocket, startTCPServer) +import Simplex.Messaging.Util (ifM, tshow) +import Simplex.Messaging.Version (mkVersionRange) import UnliftIO import UnliftIO.Concurrent @@ -52,54 +49,107 @@ pattern ANY_ADDR_V4 :: (IsString a, Eq a) => a pattern ANY_ADDR_V4 = "0.0.0.0" pattern DISCOVERY_PORT :: (IsString a, Eq a) => a -pattern DISCOVERY_PORT = "5226" +pattern DISCOVERY_PORT = "5227" + +startSession :: MonadIO m => Maybe Text -> (N.HostAddress, Word16) -> C.KeyHash -> m ((C.APublicDhKey, C.APrivateDhKey), C.PrivateKeyEd25519, Announce, SignedOOB) +startSession deviceName serviceAddress caFingerprint = liftIO $ do + sessionStart <- getSystemTime + dh@(C.APublicDhKey C.SX25519 sessionDH, _) <- C.generateDhKeyPair C.SX25519 + (C.APublicVerifyKey C.SEd25519 sigPubKey, C.APrivateSignKey C.SEd25519 sigSecretKey) <- C.generateSignatureKeyPair C.SEd25519 + let + announce = + Announce + { versionRange = announceVersionRange, + sessionStart, + announceCounter = 0, + serviceAddress, + caFingerprint, + sessionDH, + announceKey = sigPubKey + } + authToken <- decodeUtf8 . B64U.encode <$> getRandomBytes 12 + let + oob = + OOB + { caFingerprint, + authToken, + host = decodeUtf8 . strEncode $ THIPv4 . N.hostAddressToTuple $ fst serviceAddress, + port = snd serviceAddress, + version = mkVersionRange 1 1, + appName = "simplex-chat", + sigPubKey, + deviceName + } + pure (dh, sigSecretKey, announce, signOOB sigSecretKey oob) + +getLocalAddress :: MonadIO m => TMVar Int -> m (Maybe N.HostAddress) +getLocalAddress subscribers = liftIO $ do + probe <- mkIpProbe + let bytes = smpEncode probe + withListener subscribers $ \receiver -> + withSender $ \sender -> do + UDP.send sender bytes + let expect = do + UDP.recvFrom receiver >>= \case + (p, _) | p /= bytes -> expect + (_, UDP.ClientSockAddr (N.SockAddrInet _port host) _cmsg) -> pure host + (_, UDP.ClientSockAddr _badAddr _) -> error "receiving from IPv4 socket" + timeout 1000000 expect + +mkIpProbe :: MonadIO m => m IpProbe +mkIpProbe = do + randomNonce <- liftIO $ getRandomBytes 32 + pure IpProbe {versionRange = ipProbeVersionRange, randomNonce} -- | Announce tls server, wait for connection and attach http2 client to it. -- -- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: StrEncoding a => Tasks -> a -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) -announceRevHTTP2 tasks invite credentials finishAction = do +announceRevHTTP2 :: Tasks -> (C.PrivateKeyEd25519, Announce) -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 tasks (sigKey, announce@Announce {caFingerprint, serviceAddress=(host, _port)}) credentials finishAction = do httpClient <- newEmptyMVar started <- newEmptyTMVarIO finished <- newEmptyMVar _ <- forkIO $ readMVar finished >> finishAction -- attach external cleanup action to session lock - announcer <- async . liftIO . whenM (atomically $ takeTMVar started) $ do - logInfo $ "Starting announcer for " <> tshow (strEncode invite) - runAnnouncer (strEncode invite) + announcer <- async . liftIO $ atomically (takeTMVar started) >>= \case + Nothing -> pure () -- TLS server failed to start, skipping announcer + Just givenPort -> do + logInfo $ "Starting announcer for " <> ident <> " at " <> tshow (host, givenPort) + runAnnouncer (sigKey, announce {serviceAddress = (host, fromIntegral givenPort)}) tasks `registerAsync` announcer tlsServer <- startTLSServer started credentials $ \tls -> do - logInfo $ "Incoming connection for " <> tshow (strEncode invite) + logInfo $ "Incoming connection for " <> ident cancel announcer runHTTP2Client finished httpClient tls `catchAny` (logError . tshow) - logInfo $ "Client finished for " <> tshow (strEncode invite) - -- BUG: this should be handled in HTTP2Client wrapper - _ <- forkIO $ do - waitCatch tlsServer >>= \case - Left err | fromException err == Just AsyncCancelled -> logDebug "tlsServer cancelled" - Left err -> do - logError $ "tlsServer failed to start: " <> tshow err - void $ tryPutMVar httpClient $ Left HCNetworkError - void . atomically $ tryPutTMVar started False - Right () -> pure () - void $ tryPutMVar finished () + logInfo $ "Client finished for " <> ident + -- BUG: this should be handled in HTTP2Client wrapper, partially handled in startTLSServer + _ <- forkIO $ waitCatch tlsServer >> void (tryPutMVar finished ()) tasks `registerAsync` tlsServer - logInfo $ "Waiting for client for " <> tshow (strEncode invite) + logInfo $ "Waiting for client for " <> ident readMVar httpClient + where + ident = decodeUtf8 $ strEncode caFingerprint --- | Broadcast invite with link-local datagrams -runAnnouncer :: ByteString -> IO () -runAnnouncer inviteBS = do - bracket (UDP.clientSocket MULTICAST_ADDR_V4 DISCOVERY_PORT False) UDP.close $ \sock -> do - let raw = UDP.udpSocket sock - N.setSocketOption raw N.Broadcast 1 - N.setSocketOption raw N.ReuseAddr 1 - forever $ do - UDP.send sock inviteBS +-- | Send replay-proof announce datagrams +runAnnouncer :: (C.PrivateKeyEd25519, Announce) -> IO () +runAnnouncer (announceKey, initialAnnounce) = withSender $ loop initialAnnounce + where + loop announce sock = do + UDP.send sock $ smpEncode (signAnnounce announceKey announce) threadDelay 1000000 + loop announce {announceCounter = announceCounter announce + 1} sock --- XXX: Do we need to start multiple TLS servers for different mobile hosts? -startTLSServer :: (MonadUnliftIO m) => TMVar Bool -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) -startTLSServer started credentials = async . liftIO . runTransportServer started DISCOVERY_PORT serverParams defaultTransportServerConfig +startTLSServer :: (MonadUnliftIO m) => TMVar (Maybe N.PortNumber) -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) +startTLSServer started credentials server = async . liftIO $ do + startedOk <- newEmptyTMVarIO + bracketOnError (startTCPServer startedOk "0") (\_e -> void . atomically $ tryPutTMVar started Nothing) $ \socket -> + ifM + (atomically $ readTMVar startedOk) + do + port <- N.socketPort socket + logInfo $ "System-assigned port: " <> tshow port + atomically $ putTMVar started (Just port) + runTransportServerSocket startedOk (pure socket) "RCP TLS" serverParams defaultTransportServerConfig server + (void . atomically $ tryPutTMVar started Nothing) where serverParams = def @@ -123,22 +173,40 @@ runHTTP2Client finishedVar clientVar tls = -- TODO connection timeout config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} -withListener :: (MonadUnliftIO m) => (UDP.ListenSocket -> m a) -> m a -withListener = bracket openListener closeListener +withSender :: MonadUnliftIO m => (UDP.UDPSocket -> m a) -> m a +withSender = bracket (liftIO $ UDP.clientSocket MULTICAST_ADDR_V4 DISCOVERY_PORT False) (liftIO . UDP.close) -openListener :: (MonadIO m) => m UDP.ListenSocket -openListener = liftIO $ do +withListener :: MonadUnliftIO m => TMVar Int -> (UDP.ListenSocket -> m a) -> m a +withListener subscribers = bracket (openListener subscribers) (closeListener subscribers) + +openListener :: MonadIO m => TMVar Int -> m UDP.ListenSocket +openListener subscribers = liftIO $ do sock <- UDP.serverSocket (MULTICAST_ADDR_V4, read DISCOVERY_PORT) logDebug $ "Discovery listener socket: " <> tshow sock let raw = UDP.listenSocket sock - N.setSocketOption raw N.Broadcast 1 - void $ setMembership raw (listenerHostAddr4 sock) True + -- N.setSocketOption raw N.Broadcast 1 + joinMulticast subscribers raw (listenerHostAddr4 sock) pure sock -closeListener :: MonadIO m => UDP.ListenSocket -> m () -closeListener sock = liftIO $ do - UDP.stop sock - void $ setMembership (UDP.listenSocket sock) (listenerHostAddr4 sock) False +closeListener :: MonadIO m => TMVar Int -> UDP.ListenSocket -> m () +closeListener subscribers sock = liftIO $ + partMulticast subscribers (UDP.listenSocket sock) (listenerHostAddr4 sock) `finally` UDP.stop sock + +joinMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () +joinMulticast subscribers sock group = do + now <- atomically $ takeTMVar subscribers + when (now == 0) $ do + setMembership sock group True >>= \case + Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) + Right () -> atomically $ putTMVar subscribers (now + 1) + +partMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () +partMulticast subscribers sock group = do + now <- atomically $ takeTMVar subscribers + when (now == 1) $ + setMembership sock group False >>= \case + Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) + Right () -> atomically $ putTMVar subscribers (now - 1) listenerHostAddr4 :: UDP.ListenSocket -> N.HostAddress listenerHostAddr4 sock = case UDP.mySockAddr sock of @@ -150,11 +218,11 @@ recvAnnounce sock = liftIO $ do (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock pure (source, invite) -connectRevHTTP2 :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (HTTP2Request -> m ()) -> m () -connectRevHTTP2 host fingerprint = connectTLSClient host fingerprint . attachHTTP2Server +connectRevHTTP2 :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (HTTP2Request -> m ()) -> m () +connectRevHTTP2 serviceAddress fingerprint = connectTLSClient serviceAddress fingerprint . attachHTTP2Server -connectTLSClient :: (MonadUnliftIO m) => TransportHost -> C.KeyHash -> (Transport.TLS -> m a) -> m a -connectTLSClient host caFingerprint = runTransportClient defaultTransportClientConfig Nothing host DISCOVERY_PORT (Just caFingerprint) +connectTLSClient :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (Transport.TLS -> m a) -> m a +connectTLSClient (host, port) caFingerprint = runTransportClient defaultTransportClientConfig Nothing host (show port) (Just caFingerprint) attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () attachHTTP2Server processRequest tls = do diff --git a/src/Simplex/Chat/Remote/Multicast.hsc b/src/Simplex/Chat/Remote/Multicast.hsc index ea015c18e3..3919b4423f 100644 --- a/src/Simplex/Chat/Remote/Multicast.hsc +++ b/src/Simplex/Chat/Remote/Multicast.hsc @@ -10,12 +10,15 @@ import Network.Socket NB: Group membership is per-host, not per-process. A socket is only used to access system interface for groups. -} -setMembership :: Socket -> HostAddress -> Bool -> IO Bool +setMembership :: Socket -> HostAddress -> Bool -> IO (Either CInt ()) setMembership sock group membership = allocaBytes #{size struct ip_mreq} $ \mReqPtr -> do #{poke struct ip_mreq, imr_multiaddr} mReqPtr group #{poke struct ip_mreq, imr_interface} mReqPtr (0 :: HostAddress) -- attempt to contact the group on ANY interface - withFdSocket sock $ \fd -> - (/= 0) <$> c_setsockopt fd c_IPPROTO_IP flag (castPtr mReqPtr) (#{size struct ip_mreq}) + withFdSocket sock $ \fd -> do + rc <- c_setsockopt fd c_IPPROTO_IP flag (castPtr mReqPtr) (#{size struct ip_mreq}) + if rc == 0 + then pure $ Right () + else pure $ Left rc where flag = if membership then c_IP_ADD_MEMBERSHIP else c_IP_DROP_MEMBERSHIP diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index 2deb177775..45bea60663 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -66,8 +66,8 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) -- * Client side / desktop -createRemoteHostClient :: HTTP2Client -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient -createRemoteHostClient httpClient desktopName = do +createRemoteHostClient :: HTTP2Client -> dh -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient +createRemoteHostClient httpClient todo'dhKey desktopName = do logDebug "Sending initial hello" sendRemoteCommand' httpClient localEncoding Nothing RCHello {deviceName = desktopName} >>= \case RRHello {encoding, deviceName = mobileName, encryptFiles} -> do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 6611d04471..4507c3de79 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -1,18 +1,39 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE GADTs #-} module Simplex.Chat.Remote.Types where import Control.Exception +import Control.Monad +import Crypto.Error (eitherCryptoError) +import qualified Crypto.PubKey.Ed25519 as Ed25519 import qualified Data.Aeson.TH as J +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteArray (convert) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Foldable (toList) import Data.Int (Int64) import Data.Text (Text) +import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8) +import Data.Time.Clock.System (SystemTime) +import Data.Word (Word16) +import Network.HTTP.Types (parseSimpleQuery) +import Network.HTTP.Types.URI (renderSimpleQuery, urlDecode, urlEncode) +import qualified Network.Socket as N import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import Simplex.Messaging.Encoding (Encoding (..)) +import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import Simplex.Messaging.Version (VersionRange, mkVersionRange) import UnliftIO data RemoteHostClient = RemoteHostClient @@ -66,7 +87,6 @@ data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, storePath :: FilePath, displayName :: Text, - remoteCtrlOOB :: RemoteCtrlOOB, sessionActive :: Bool } deriving (Show) @@ -98,6 +118,161 @@ data RemoteFile = RemoteFile } deriving (Show) +ipProbeVersionRange :: VersionRange +ipProbeVersionRange = mkVersionRange 1 1 + +data IpProbe = IpProbe + { versionRange :: VersionRange, + randomNonce :: ByteString + } deriving (Show) + +instance Encoding IpProbe where + smpEncode IpProbe {versionRange, randomNonce} = smpEncode (versionRange, 'I', randomNonce) + + smpP = IpProbe <$> (smpP <* "I") *> smpP + +announceVersionRange :: VersionRange +announceVersionRange = mkVersionRange 1 1 + +data Announce = Announce + { versionRange :: VersionRange, + sessionStart :: SystemTime, + announceCounter :: Word16, + serviceAddress :: (N.HostAddress, Word16), + caFingerprint :: C.KeyHash, + sessionDH :: C.PublicKeyX25519, + announceKey :: C.PublicKeyEd25519 + } deriving (Show) + +instance Encoding Announce where + smpEncode Announce {versionRange, sessionStart, announceCounter, serviceAddress, caFingerprint, sessionDH, announceKey} = + smpEncode (versionRange, 'A', sessionStart, announceCounter, serviceAddress) + <> smpEncode (caFingerprint, sessionDH, announceKey) + + smpP = Announce <$> (smpP <* "A") <*> smpP <*> smpP <*> smpP <*> smpP <*> smpP <*> smpP + +data SignedAnnounce = SignedAnnounce Announce (C.Signature 'C.Ed25519) + +instance Encoding SignedAnnounce where + smpEncode (SignedAnnounce ann (C.SignatureEd25519 sig)) = smpEncode (ann, convert sig :: ByteString) + + smpP = do + sa <- SignedAnnounce <$> smpP <*> signatureP + unless (verifySignedAnnounce sa) $ fail "bad announce signature" + pure sa + where + signatureP = do + bs <- smpP :: A.Parser ByteString + case eitherCryptoError (Ed25519.signature bs) of + Left ce -> fail $ show ce + Right ok -> pure $ C.SignatureEd25519 ok + +signAnnounce :: C.PrivateKey C.Ed25519 -> Announce -> SignedAnnounce +signAnnounce announceSecret ann = SignedAnnounce ann sig + where + sig = + case C.sign (C.APrivateSignKey C.SEd25519 announceSecret) (smpEncode ann) of + C.ASignature C.SEd25519 s -> s + _ -> error "signing with ed25519" + +verifySignedAnnounce :: SignedAnnounce -> Bool +verifySignedAnnounce (SignedAnnounce ann@Announce {announceKey} sig) = C.verify aKey aSig (smpEncode ann) + where + aKey = C.APublicVerifyKey C.SEd25519 announceKey + aSig = C.ASignature C.SEd25519 sig + +data OOB = OOB + { -- authority part + caFingerprint :: C.KeyHash, + authToken :: Text, + host :: Text, + port :: Word16, + -- query part + version :: VersionRange, -- v= + appName :: Text, -- app= + sigPubKey :: C.PublicKeyEd25519, -- key= + deviceName :: Maybe Text -- device= + } + deriving (Eq, Show) + +instance StrEncoding OOB where + strEncode OOB {caFingerprint, authToken, host, port, version, appName, sigPubKey, deviceName} = + schema <> "://" <> authority <> "#/?" <> renderSimpleQuery False query + where + schema = "xrcp" + authority = + mconcat + [ strEncode caFingerprint, + ":", + encodeUtf8 authToken, + "@", + encodeUtf8 host, + ":", + strEncode port + ] + query = + [ ("v", strEncode version), + ("app", encodeUtf8 appName), + ("key", strEncode $ C.encodePubKey sigPubKey) + ] + ++ [("device", urlEncode True $ encodeUtf8 name) | name <- toList deviceName] + + strP = do + _ <- A.string "xrcp://" + caFingerprint <- strP + _ <- A.char ':' + authToken <- decodeUtf8Lenient <$> A.takeWhile (/= '@') + _ <- A.char '@' + host <- decodeUtf8Lenient <$> A.takeWhile (/= ':') + _ <- A.char ':' + port <- strP + + _ <- A.string "#/?" + q <- parseSimpleQuery <$> A.takeByteString + version <- maybe (fail "missing version") (either fail pure . strDecode) (lookup "v" q) + appName <- maybe (fail "missing appName") (pure . decodeUtf8Lenient) (lookup "app" q) + sigPubKeyB64 <- maybe (fail "missing key") pure (lookup "key" q) + sigPubKey <- either fail pure $ strDecode sigPubKeyB64 >>= C.decodePubKey + let deviceName = fmap (decodeUtf8Lenient . urlDecode True) (lookup "device" q) + pure OOB {caFingerprint, authToken, host, port, version, appName, sigPubKey, deviceName} + +data SignedOOB = SignedOOB OOB (C.Signature 'C.Ed25519) + deriving (Eq, Show) + +instance StrEncoding SignedOOB where + strEncode (SignedOOB oob sig) = strEncode oob <> "&sig=" <> strEncode (C.signatureBytes sig) + + strDecode s = do + unless (B.length sig == sigLen) $ Left "bad size" + unless ("&sig=" `B.isPrefixOf` sig) $ Left "bad signature prefix" + signedOOB <- SignedOOB <$> strDecode oob <*> (strDecode (B.drop 5 sig) >>= C.decodeSignature) + unless (verifySignedOOB signedOOB) $ Left "bad signature" + pure signedOOB + where + l = B.length s + (oob, sig) = B.splitAt (l - sigLen) s + sigLen = 93 -- &sig= + ed25519 sig size in base64 (88) + + -- XXX: strP is used in chat command parser, but default strP assumes bas64url-encoded bytestring, where OOB is an URL-like + strP = A.takeWhile (/= ' ') >>= either fail pure . strDecode + +signOOB :: C.PrivateKey C.Ed25519 -> OOB -> SignedOOB +signOOB key oob = SignedOOB oob sig + where + sig = + case C.sign (C.APrivateSignKey C.SEd25519 key) (strEncode oob) of + C.ASignature C.SEd25519 s -> s + _ -> error "signing with ed25519" + +verifySignedOOB :: SignedOOB -> Bool +verifySignedOOB (SignedOOB oob@OOB {sigPubKey} sig) = C.verify aKey aSig (strEncode oob) + where + aKey = C.APublicVerifyKey C.SEd25519 sigPubKey + aSig = C.ASignature C.SEd25519 sig + +decodeOOBLink :: Text -> Either String OOB +decodeOOBLink = fmap (\(SignedOOB oob _verified) -> oob) . strDecode . encodeUtf8 + data PlatformEncoding = PESwift | PEKotlin @@ -125,8 +300,6 @@ $(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) $(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) -$(J.deriveJSON defaultJSON ''RemoteCtrlOOB) - $(J.deriveJSON defaultJSON ''RemoteHostInfo) $(J.deriveJSON defaultJSON ''RemoteCtrl) diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index a4c2ef85e1..0dfe665b2f 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -6,13 +6,14 @@ module Simplex.Chat.Store.Remote where import Control.Monad.Except import Data.Int (Int64) +import Data.Maybe (fromMaybe) import Data.Text (Text) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Chat.Store.Shared import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId @@ -39,8 +40,9 @@ toRemoteHost (remoteHostId, storePath, displayName, caKey, C.SignedObject caCert deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) -insertRemoteCtrl :: DB.Connection -> RemoteCtrlOOB -> IO RemoteCtrlInfo -insertRemoteCtrl db RemoteCtrlOOB {fingerprint, displayName} = do +insertRemoteCtrl :: DB.Connection -> SignedOOB -> IO RemoteCtrlInfo +insertRemoteCtrl db (SignedOOB OOB {deviceName, caFingerprint = fingerprint} _) = do + let displayName = fromMaybe "" deviceName DB.execute db "INSERT INTO remote_controllers (display_name, fingerprint) VALUES (?,?)" (displayName, fingerprint) remoteCtrlId <- insertedRowId db pure RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted = Nothing, sessionActive = False} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9ae00159b7..1810b62189 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -269,8 +269,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] - CRRemoteHostCreated RemoteHostInfo {remoteHostId, remoteCtrlOOB} -> ("remote host " <> sShow remoteHostId <> " created") : viewRemoteCtrlOOBData remoteCtrlOOB + CRRemoteHostCreated RemoteHostInfo {remoteHostId} -> ["remote host " <> sShow remoteHostId <> " created"] CRRemoteHostList hs -> viewRemoteHosts hs + CRRemoteHostStarted {remoteHost = RemoteHostInfo {remoteHostId = rhId}, sessionOOB} -> ["remote host " <> sShow rhId <> " started", "connection code:", plain sessionOOB] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> @@ -447,7 +448,7 @@ viewGroupSubscribed :: GroupInfo -> [StyledString] viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] showSMPServer :: SMPServer -> String -showSMPServer = B.unpack . strEncode . host +showSMPServer srv = B.unpack $ strEncode srv.host viewHostEvent :: AProtocolType -> TransportHost -> String viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h) @@ -1659,10 +1660,6 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo where parens s = " (" <> s <> ")" -viewRemoteCtrlOOBData :: RemoteCtrlOOB -> [StyledString] -viewRemoteCtrlOOBData RemoteCtrlOOB {fingerprint} = - ["connection code:", plain $ strEncode fingerprint] - viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] viewRemoteHosts = \case [] -> ["No remote hosts"] diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index be1d3c1a2e..ccbd543e9a 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -8,12 +8,12 @@ module RemoteTests where import ChatClient import ChatTests.Utils import Control.Logger.Simple -import Control.Monad import qualified Data.Aeson as J import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M +import Data.String (fromString) import Network.HTTP.Types (ok200) import qualified Network.HTTP2.Client as C import qualified Network.HTTP2.Server as S @@ -23,11 +23,12 @@ import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File -import Simplex.Chat.Remote.Types import qualified Simplex.Chat.Remote.Discovery as Discovery +import Simplex.Chat.Remote.Types import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) -import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Encoding (smpDecode) +import Simplex.Messaging.Encoding.String (strDecode, strEncode) import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) @@ -44,6 +45,7 @@ remoteTests :: SpecWith FilePath remoteTests = describe "Remote" $ do it "generates usable credentials" genCredentialsTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test + it "OOB encoding, decoding, and signatures are correct" oobCodecTest it "performs protocol handshake" remoteHandshakeTest it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check it "sends messages" remoteMessageTest @@ -59,8 +61,9 @@ genCredentialsTest _tmp = do started <- newEmptyTMVarIO bracket (Discovery.startTLSServer started credentials serverHandler) cancel $ \_server -> do ok <- atomically (readTMVar started) - unless ok $ error "TLS server failed to start" - Discovery.connectTLSClient "127.0.0.1" fingerprint clientHandler + port <- maybe (error "TLS server failed to start") pure ok + logNote $ "Assigned port: " <> tshow port + Discovery.connectTLSClient ("127.0.0.1", fromIntegral port) fingerprint clientHandler where serverHandler serverTls = do logNote "Sending from server" @@ -75,15 +78,28 @@ genCredentialsTest _tmp = do -- * UDP discovery and rever HTTP2 +oobCodecTest :: (HasCallStack) => FilePath -> IO () +oobCodecTest _tmp = do + subscribers <- newTMVarIO 0 + localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure + (fingerprint, _credentials) <- genTestCredentials + (_dhKey, _sigKey, _ann, signedOOB@(SignedOOB oob _sig)) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint + verifySignedOOB signedOOB `shouldBe` True + strDecode (strEncode oob) `shouldBe` Right oob + strDecode (strEncode signedOOB) `shouldBe` Right signedOOB + announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () announceDiscoverHttp2Test _tmp = do + subscribers <- newTMVarIO 0 + localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure (fingerprint, credentials) <- genTestCredentials + (_dhKey, sigKey, ann, _oob) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint tasks <- newTVarIO [] finished <- newEmptyMVar controller <- async $ do logNote "Controller: starting" bracket - (Discovery.announceRevHTTP2 tasks fingerprint credentials (putMVar finished ()) >>= either (fail . show) pure) + (Discovery.announceRevHTTP2 tasks (sigKey, ann) credentials (putMVar finished ()) >>= either (fail . show) pure) closeHTTP2Client ( \http -> do logNote "Controller: got client" @@ -94,11 +110,14 @@ announceDiscoverHttp2Test _tmp = do Right HTTP2Response {} -> logNote "Controller: got response" ) - host <- async $ Discovery.withListener $ \sock -> do - (N.SockAddrInet _port addr, invite) <- Discovery.recvAnnounce sock - strDecode invite `shouldBe` Right fingerprint - logNote "Host: connecting" - server <- async $ Discovery.connectTLSClient (THIPv4 $ N.hostAddressToTuple addr) fingerprint $ \tls -> do + host <- async $ Discovery.withListener subscribers $ \sock -> do + (N.SockAddrInet _port addr, sigAnn) <- Discovery.recvAnnounce sock + SignedAnnounce Announce {caFingerprint, serviceAddress=(hostAddr, port)} _sig <- either fail pure $ smpDecode sigAnn + caFingerprint `shouldBe` fingerprint + addr `shouldBe` hostAddr + let service = (THIPv4 $ N.hostAddressToTuple hostAddr, port) + logNote $ "Host: connecting to " <> tshow service + server <- async $ Discovery.connectTLSClient service fingerprint $ \tls -> do logNote "Host: got tls" flip Discovery.attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do logNote "Host: got request" @@ -213,7 +232,7 @@ remoteStoreFileTest = -- send file not encrypted locally on mobile host desktop ##> "/_send @2 json {\"filePath\": \"test_1.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"sending a file\"}}" desktop <# "@bob sending a file" - desktop <# "/f @bob test_1.pdf" + desktop <# "/f @bob test_1.pdf" desktop <## "use /fc 1 to cancel sending" bob <# "alice> sending a file" bob <# "alice> sends file test_1.pdf (266.0 KiB / 272376 bytes)" @@ -242,7 +261,7 @@ remoteStoreFileTest = -- send file encrypted locally on mobile host desktop ##> ("/_send @2 json {\"fileSource\": {\"filePath\":\"test_2.pdf\", \"cryptoArgs\": " <> LB.unpack (J.encode cfArgs) <> "}, \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}") - desktop <# "/f @bob test_2.pdf" + desktop <# "/f @bob test_2.pdf" desktop <## "use /fc 2 to cancel sending" bob <# "alice> sends file test_2.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 2 [/ | ] to receive it" @@ -372,21 +391,30 @@ remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile startRemote :: TestCC -> TestCC -> IO () startRemote mobile desktop = do + desktop ##> "/set device name My desktop" + desktop <## "ok" desktop ##> "/create remote host" desktop <## "remote host 1 created" - desktop <## "connection code:" - fingerprint <- getTermLine desktop - + -- A new host is started [automatically] by UI desktop ##> "/start remote host 1" desktop <## "ok" + desktop <## "remote host 1 started" + desktop <## "connection code:" + oobLink <- getTermLine desktop + OOB {caFingerprint = oobFingerprint} <- either (fail . mappend "OOB link failed: ") pure $ decodeOOBLink (fromString oobLink) + -- Desktop displays OOB QR code + mobile ##> "/set device name Mobile" + mobile <## "ok" mobile ##> "/start remote ctrl" mobile <## "ok" mobile <## "remote controller announced" mobile <## "connection code:" - fingerprint' <- getTermLine mobile - fingerprint' `shouldBe` fingerprint - mobile ##> ("/register remote ctrl " <> fingerprint' <> " " <> "My desktop") + annFingerprint <- getTermLine mobile + -- The user scans OOB QR code and confirms it matches the announced stuff + fromString annFingerprint `shouldBe` strEncode oobFingerprint + + mobile ##> ("/register remote ctrl " <> oobLink) mobile <## "remote controller 1 registered" mobile ##> "/accept remote ctrl 1" mobile <## "ok" -- alternative scenario: accepted before controller start From f34bbdbd9c13b4f4dbc7bf54515b1019b7655f17 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 30 Oct 2023 20:40:20 +0400 Subject: [PATCH 127/294] core: improve group link protocol (immediately establish group connection without first creating contact) (#3288) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 74 +++- src/Simplex/Chat/Controller.hs | 3 + .../M20231030_xgrplinkmem_received.hs | 18 + src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Protocol.hs | 20 +- src/Simplex/Chat/Store/Groups.hs | 138 ++++++- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Types.hs | 16 + src/Simplex/Chat/View.hs | 3 + tests/Bots/DirectoryTests.hs | 8 +- tests/ChatClient.hs | 10 + tests/ChatTests/Groups.hs | 338 +++++++++++++++++- tests/ChatTests/Utils.hs | 10 +- tests/ProtocolTests.hs | 8 +- 15 files changed, 613 insertions(+), 39 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index cdb659a66e..96c56fdbb7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -118,6 +118,7 @@ library Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash Simplex.Chat.Migrations.M20231010_member_settings Simplex.Chat.Migrations.M20231019_indexes + Simplex.Chat.Migrations.M20231030_xgrplinkmem_received Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4b6359f135..52bf4a1852 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2599,6 +2599,24 @@ acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvI setCommandConnId db user cmdId connId pure ct +acceptGroupJoinRequestAsync :: ChatMonad m => User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> Maybe IncognitoProfile -> m GroupMember +acceptGroupJoinRequestAsync + user + gInfo@GroupInfo {groupProfile, membership} + ucr@UserContactRequest {agentInvitationId = AgentInvId invId} + gLinkMemRole + incognitoProfile = do + gVar <- asks idsDrg + (groupMemberId, memberId) <- withStore $ \db -> createAcceptedMember db gVar user gInfo ucr gLinkMemRole + let Profile {displayName} = profileToSendOnAccept user incognitoProfile + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + msg = XGrpLinkInv $ GroupLinkInvitation (MemberIdRole userMemberId userRole) displayName (MemberIdRole memberId gLinkMemRole) groupProfile + subMode <- chatReadVar subscriptionMode + connIds <- agentAcceptContactAsync user True invId msg subMode + withStore $ \db -> do + liftIO $ createAcceptedMemberConnection db user connIds ucr groupMemberId subMode + getGroupMemberById db user groupMemberId + profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Profile profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ip) Nothing where @@ -3402,8 +3420,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO update member profile pure () | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" + XInfo _ -> pure () -- sent when connecting via group link XOk -> pure () - _ -> messageError "INFO from member must have x.grp.mem.info" + _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () CON -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -3424,11 +3443,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do GCInviteeMember -> do memberConnectedChatItem gInfo m toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} + let Connection {viaUserContactLink} = conn + when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> processIntro intro `catchChatError` (toView . CRChatError (Just user)) where + sendXGrpLinkMem = do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + profileToSend = profileToSendOnAccept user profileMode + void $ sendDirectMessage conn (XGrpLinkMem profileToSend) (GroupId groupId) processIntro intro@GroupMemberIntro {introId} = do void $ sendDirectMessage conn (XGrpMemIntro $ memberInfo (reMember intro)) (GroupId groupId) withStore' $ \db -> updateIntroStatus db introId GMIntroSent @@ -3461,6 +3486,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg msgMeta XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId msgMeta XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName msgMeta + -- XInfo p -> xInfoMember gInfo m' p -- TODO use for member profile update + XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg msgMeta XGrpMemIntro memInfo -> xGrpMemIntro gInfo m' memInfo XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv @@ -3721,7 +3748,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case - Just (UserContactLink {autoAccept}, groupId_, _) -> + Just (UserContactLink {autoAccept}, groupId_, gLinkMemRole) -> case autoAccept of Just AutoAccept {acceptIncognito} -> case groupId_ of Nothing -> do @@ -3732,8 +3759,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db user groupId let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - ct <- acceptContactRequestAsync user cReq profileMode - toView $ CRAcceptingGroupJoinRequest user gInfo ct + if isCompatibleRange chatVRange groupLinkNoContactVRange + then do + mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode + createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CRAcceptingGroupJoinRequestMember user gInfo mem + else do + ct <- acceptContactRequestAsync user cReq profileMode + toView $ CRAcceptingGroupJoinRequest user gInfo ct _ -> toView $ CRReceivedContactRequest user cReq _ -> pure () @@ -4446,6 +4479,33 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | otherwise -> Nothing in setPreference_ SCFTimedMessages ctUserTMPref' ctUserPrefs + -- TODO use for member profile update + -- xInfoMember :: GroupInfo -> GroupMember -> Profile -> m () + -- xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' + + xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> m () + xGrpLinkMem gInfo@GroupInfo {membership} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do + xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId + if viaGroupLink && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived + then do + m' <- processMemberProfileUpdate gInfo m p' + withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True + let connectedIncognito = memberIncognito membership + probeMatchingMemberContact m' connectedIncognito + else messageError "x.grp.link.mem error: invalid group link host profile update" + + processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> m GroupMember + processMemberProfileUpdate gInfo m@GroupMember {memberContactId} p' = + case memberContactId of + Nothing -> do + m' <- withStore $ \db -> updateMemberProfile db user m p' + toView $ CRGroupMemberUpdated user gInfo m m' + pure m' + Just mContactId -> do + mCt <- withStore $ \db -> getContact db user mContactId + Contact {profile} <- processContactProfileUpdate mCt p' True + pure m {memberProfile = profile} + createFeatureEnabledItems :: Contact -> m () createFeatureEnabledItems ct@Contact {mergedPreferences} = forM_ allChatFeatures $ \(ACF f) -> do @@ -4707,6 +4767,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ct <- withStore $ \db -> createDirectContact db user conn' p toView $ CRContactConnecting user ct pure conn' + XGrpLinkInv glInv -> do + (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db user conn' glInv + toView $ CRGroupLinkConnecting user gInfo host + pure conn' -- TODO show/log error, other events in SMP confirmation _ -> pure conn' @@ -5488,7 +5552,7 @@ getCreateActiveUser st testView = do where loop = do displayName <- getContactName - withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} True) >>= \case + withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) (profileFromName displayName) True) >>= \case Left SEDuplicateName -> do putStrLn "chosen display name is already used by another profile on this device, choose another one" loop diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 74501ad1e5..5f386dea0d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -474,6 +474,7 @@ data ChatResponse | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} + | CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} @@ -489,6 +490,7 @@ data ChatResponse | CRSentConfirmation {user :: User} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} + | CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} | CRContactDeleted {user :: User, contact :: Contact} | CRContactDeletedByContact {user :: User, contact :: Contact} @@ -559,6 +561,7 @@ data ChatResponse | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} + | CRAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs b/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs new file mode 100644 index 0000000000..cf4aee2531 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231030_xgrplinkmem_received where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231030_xgrplinkmem_received :: Query +m20231030_xgrplinkmem_received = + [sql| +ALTER TABLE group_members ADD COLUMN xgrplinkmem_received INTEGER NOT NULL DEFAULT 0; +|] + +down_m20231030_xgrplinkmem_received :: Query +down_m20231030_xgrplinkmem_received = + [sql| +ALTER TABLE group_members DROP COLUMN xgrplinkmem_received; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index cadb7caf42..8e277a9789 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -146,6 +146,7 @@ CREATE TABLE group_members( updated_at TEXT CHECK(updated_at NOT NULL), member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, show_messages INTEGER NOT NULL DEFAULT 1, + xgrplinkmem_received INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 0f69efe7c0..58aa26f284 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -51,7 +51,7 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) currentChatVersion :: Version -currentChatVersion = 2 +currentChatVersion = 3 supportedChatVRange :: VersionRange supportedChatVRange = mkVersionRange 1 currentChatVersion @@ -64,6 +64,10 @@ groupNoDirectVRange = mkVersionRange 2 currentChatVersion xGrpDirectInvVRange :: VersionRange xGrpDirectInvVRange = mkVersionRange 2 currentChatVersion +-- version range that supports joining group via group link without creating direct contact +groupLinkNoContactVRange :: VersionRange +groupLinkNoContactVRange = mkVersionRange 3 currentChatVersion + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -218,6 +222,8 @@ data ChatMsgEvent (e :: MsgEncoding) where XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json + XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json + XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -559,6 +565,8 @@ data CMEventTag (e :: MsgEncoding) where XDirectDel_ :: CMEventTag 'Json XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json + XGrpLinkInv_ :: CMEventTag 'Json + XGrpLinkMem_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -606,6 +614,8 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XDirectDel_ -> "x.direct.del" XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" + XGrpLinkInv_ -> "x.grp.link.inv" + XGrpLinkMem_ -> "x.grp.link.mem" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -654,6 +664,8 @@ instance StrEncoding ACMEventTag where "x.direct.del" -> XDirectDel_ "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ + "x.grp.link.inv" -> XGrpLinkInv_ + "x.grp.link.mem" -> XGrpLinkMem_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -698,6 +710,8 @@ toCMEventTag msg = case msg of XDirectDel -> XDirectDel_ XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ + XGrpLinkInv _ -> XGrpLinkInv_ + XGrpLinkMem _ -> XGrpLinkMem_ XGrpMemNew _ -> XGrpMemNew_ XGrpMemIntro _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -795,6 +809,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" + XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" + XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -853,6 +869,8 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] + XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] + XGrpLinkMem profile -> o ["profile" .= profile] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] XGrpMemIntro memInfo -> o ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 0b296b17e8..bddca0deb4 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -31,6 +31,7 @@ module Simplex.Chat.Store.Groups getGroupAndMember, createNewGroup, createGroupInvitation, + createGroupInvitedViaLink, setViaGroupLinkHash, setGroupInvitationChatItemId, getGroup, @@ -59,6 +60,8 @@ module Simplex.Chat.Store.Groups getGroupInvitation, createNewContactMember, createNewContactMemberAsync, + createAcceptedMember, + createAcceptedMemberConnection, getContactViaMember, setNewContactMemberConnRequest, getMemberInvitation, @@ -102,6 +105,9 @@ module Simplex.Chat.Store.Groups createMemberContactInvited, updateMemberContactInvited, resetMemberContactFields, + updateMemberProfile, + getXGrpLinkMemReceived, + setXGrpLinkMemReceived, ) where @@ -412,6 +418,54 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me ) pure $ Right incognitoLdn +createGroupInvitedViaLink :: DB.Connection -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupInvitedViaLink + db + user@User {userId, userContactId} + Connection {connId, customUserProfileId} + GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile} = do + currentTs <- liftIO getCurrentTime + groupId <- insertGroup_ currentTs + hostMemberId <- insertHost_ currentTs groupId + liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) + -- using IBUnknown since host is created without contact + void $ createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs + liftIO $ setViaGroupLinkHash db groupId connId + (,) <$> getGroupInfo db user groupId <*> getGroupMemberById db user hostMemberId + where + insertGroup_ currentTs = ExceptT $ do + let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do + liftIO $ do + DB.execute + db + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + profileId <- insertedRowId db + DB.execute + db + "INSERT INTO groups (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?)" + (profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs) + insertedRowId db + insertHost_ currentTs groupId = ExceptT $ do + let fromMemberProfile = profileFromName fromMemberName + withLocalDisplayName db userId fromMemberName $ \localDisplayName -> runExceptT $ do + (_, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + let MemberIdRole {memberId, memberRole} = fromMember + liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + ) + insertedRowId db + setViaGroupLinkHash :: DB.Connection -> GroupId -> Int64 -> IO () setViaGroupLinkHash db groupId connId = DB.execute @@ -713,6 +767,47 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) ) +createAcceptedMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> ExceptT StoreError IO (GroupMemberId, MemberId) +createAcceptedMember + db + gVar + User {userId, userContactId} + GroupInfo {groupId} + UserContactRequest {localDisplayName, profileId} + memberRole = do + liftIO $ + DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + createWithRandomId gVar $ \memId -> do + createdAt <- liftIO getCurrentTime + insertMember_ (MemberId memId) createdAt + groupMemberId <- liftIO $ insertedRowId db + pure (groupMemberId, MemberId memId) + where + insertMember_ memberId createdAt = + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) + ) + +createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () +createAcceptedMemberConnection + db + user@User {userId} + (cmdId, agentConnId) + UserContactRequest {cReqChatVRange, userContactLinkId} + groupMemberId + subMode = do + createdAt <- liftIO getCurrentTime + Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId (fromJVersionRange cReqChatVRange) Nothing (Just userContactLinkId) Nothing 0 createdAt subMode + setCommandConnId db user cmdId connId + getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact getContactViaMember db user@User {userId} GroupMember {groupMemberId} = do contactId <- @@ -768,9 +863,9 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do -- | add new member with profile createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user gInfo memInfo memCategory memStatus = do +createNewGroupMember db user gInfo memInfo@MemberInfo {profile} memCategory memStatus = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user profile currentTs let newMember = NewGroupMember { memInfo, @@ -783,8 +878,8 @@ createNewGroupMember db user gInfo memInfo memCategory memStatus = do } liftIO $ createNewMember_ db user gInfo newMember currentTs -createNewMemberProfile_ :: DB.Connection -> User -> MemberInfo -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) -createNewMemberProfile_ db User {userId} (MemberInfo _ _ _ Profile {displayName, fullName, image, contactLink, preferences}) createdAt = +createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) +createNewMemberProfile_ db User {userId} Profile {displayName, fullName, image, contactLink, preferences} createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db @@ -960,7 +1055,7 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId} Nothing -> do - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs @@ -1737,3 +1832,36 @@ createMemberContactConn_ connId <- insertedRowId db setCommandConnId db user cmdId connId pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + +updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember +updateMemberProfile db User {userId} m p' + | displayName == newName = do + liftIO $ updateContactProfile_ db userId profileId p' + pure m {memberProfile = profile} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + currentTs <- getCurrentTime + updateContactProfile_' db userId profileId p' currentTs + DB.execute + db + "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" + (ldn, currentTs, userId, groupMemberId) + DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + pure $ Right m {localDisplayName = ldn, memberProfile = profile} + where + GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m + Profile {displayName = newName} = p' + profile = toLocalProfile profileId p' localAlias + +getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool +getXGrpLinkMemReceived db mId = + ExceptT . firstRow fromOnly (SEGroupMemberNotFound mId) $ + DB.query db "SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ?" (Only mId) + +setXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> Bool -> IO () +setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" + (xGrpLinkMemReceived, currentTs, mId) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 357bfd9a28..9335ae90e6 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -86,6 +86,7 @@ import Simplex.Chat.Migrations.M20231002_conn_initiated import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash import Simplex.Chat.Migrations.M20231010_member_settings import Simplex.Chat.Migrations.M20231019_indexes +import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -171,7 +172,8 @@ schemaMigrations = ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated), ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash), ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings), - ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes) + ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes), + ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 10b0c91d04..23ed608639 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -515,6 +515,10 @@ instance ToJSON Profile where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +profileFromName :: ContactName -> Profile +profileFromName displayName = + Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} + -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool profilesMatch @@ -621,6 +625,18 @@ instance ToJSON GroupInvitation where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data GroupLinkInvitation = GroupLinkInvitation + { fromMember :: MemberIdRole, + fromMemberName :: ContactName, + invitedMember :: MemberIdRole, + groupProfile :: GroupProfile + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupLinkInvitation where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7f1e1f5c7a..8494a7fc18 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -160,6 +160,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRUserContactLinkCreated u cReq -> ttyUser u $ connReqContact_ "Your new chat address is created!" cReq CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] + CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] @@ -176,6 +177,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' + CRGroupMemberUpdated {} -> [] CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci @@ -235,6 +237,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] + CRAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 3e1c32a6f7..36b990ba35 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -195,10 +195,10 @@ testSuspendResume tmp = testJoinGroup :: HasCallStack => FilePath -> IO () testJoinGroup tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> - withNewTestChat tmp "dan" danProfile $ \dan -> do + withDirectoryServiceCfg tmp testCfgGroupLinkViaContact $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> + withNewTestChatCfg tmp testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index fae460e908..ea455a0fc1 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -146,6 +146,16 @@ mkCfgCreateGroupDirect cfg = cfg {chatVRange = groupCreateDirectVRange} groupCreateDirectVRange :: VersionRange groupCreateDirectVRange = mkVersionRange 1 1 +testCfgGroupLinkViaContact :: ChatConfig +testCfgGroupLinkViaContact = + mkCfgGroupLinkViaContact testCfg + +mkCfgGroupLinkViaContact :: ChatConfig -> ChatConfig +mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} + +groupLinkViaContactVRange :: VersionRange +groupLinkViaContactVRange = mkVersionRange 1 2 + createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey MCError diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 97b7491067..36b5cf4ea5 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -64,6 +64,16 @@ chatGroupTests = do it "own group link" testPlanGroupLinkOwn it "connecting via group link" testPlanGroupLinkConnecting it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin + describe "group links without contact" $ do + it "join via group link without creating contact" testGroupLinkNoContact + it "group link member role" testGroupLinkNoContactMemberRole + it "host incognito" testGroupLinkNoContactHostIncognito + it "invitee incognito" testGroupLinkNoContactInviteeIncognito + it "host profile received" testGroupLinkNoContactHostProfileReceived + it "existing contact merged" testGroupLinkNoContactExistingContactMerged + describe "group links without contact connection plan" $ do + it "group link without contact - known group" testPlanGroupLinkNoContactKnown + it "group link without contact - connecting" testPlanGroupLinkNoContactConnecting describe "group message errors" $ do it "show message decryption error" testGroupMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testGroupSyncRatchet @@ -280,7 +290,7 @@ testGroupShared alice bob cath checkMessages = do testNewGroupIncognito :: HasCallStack => FilePath -> IO () testNewGroupIncognito = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1735,7 +1745,7 @@ testGroupAsync tmp = do testGroupLink :: HasCallStack => FilePath -> IO () testGroupLink = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/g team" alice <## "group #team is created" @@ -1836,7 +1846,7 @@ testGroupLink = testGroupLinkDeleteGroupRejoin :: HasCallStack => FilePath -> IO () testGroupLinkDeleteGroupRejoin = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -1892,7 +1902,7 @@ testGroupLinkDeleteGroupRejoin = testGroupLinkContactUsed :: HasCallStack => FilePath -> IO () testGroupLinkContactUsed = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -1925,7 +1935,7 @@ testGroupLinkContactUsed = testGroupLinkIncognitoMembership :: HasCallStack => FilePath -> IO () testGroupLinkIncognitoMembership = - testChat4 aliceProfile bobProfile cathProfile danProfile $ + testChatCfg4 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do -- bob connected incognito to alice alice ##> "/c" @@ -2098,7 +2108,7 @@ testGroupLinkUnusedHostContactDeleted = (bob TestCC -> TestCC -> String -> IO () bobLeaveDeleteGroup alice bob group = do bob ##> ("/l " <> group) @@ -2136,7 +2146,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = (bob TestCC -> TestCC -> String -> String -> IO String createGroupBobIncognito alice bob group bobsAliceContact = do alice ##> ("/g " <> group) @@ -2174,7 +2184,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = testGroupLinkMemberRole :: HasCallStack => FilePath -> IO () testGroupLinkMemberRole = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/g team" alice <## "group #team is created" @@ -2309,7 +2319,7 @@ testGroupLinkLeaveDelete = testPlanGroupLinkOkKnown :: HasCallStack => FilePath -> IO () testPlanGroupLinkOkKnown = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -2352,7 +2362,7 @@ testPlanGroupLinkOkKnown = testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => FilePath -> IO () testPlanHostContactDeletedGroupLinkKnown = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -2398,7 +2408,7 @@ testPlanHostContactDeletedGroupLinkKnown = testPlanGroupLinkOwn :: HasCallStack => FilePath -> IO () testPlanGroupLinkOwn tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfgGroupLinkViaContact "alice" aliceProfile $ \alice -> do alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" @@ -2458,13 +2468,13 @@ testPlanGroupLinkOwn tmp = testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO () testPlanGroupLinkConnecting tmp = do - gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -2478,13 +2488,13 @@ testPlanGroupLinkConnecting tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - withTestChat tmp "alice" $ \alice -> do + withTestChatCfg tmp cfg "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - withTestChat tmp "bob" $ \bob -> do + withTestChatCfg tmp cfg "bob" $ \bob -> do threadDelay 500000 bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: connecting" @@ -2495,10 +2505,12 @@ testPlanGroupLinkConnecting tmp = do bob ##> ("/c " <> gLink) bob <## "group link: connecting" + where + cfg = testCfgGroupLinkViaContact testPlanGroupLinkLeaveRejoin :: HasCallStack => FilePath -> IO () testPlanGroupLinkLeaveRejoin = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -2578,6 +2590,296 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" +testGroupLinkNoContact :: HasCallStack => FilePath -> IO () +testGroupLinkNoContact = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] + alice ##> "/contacts" + bob ##> "/contacts" + + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi there" + alice <# "#team bob> hi there" + +testGroupLinkNoContactMemberRole :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactMemberRole = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team observer" + gLink <- getGroupLink alice "team" GRObserver True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + + alice ##> "/ms team" + alice + <### [ "alice (Alice): owner, you, created group", + "bob (Bob): observer, invited, connected" + ] + + bob ##> "/ms team" + bob + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): observer, you, connected" + ] + + bob ##> "#team hi there" + bob <## "#team: you don't have permission to send messages" + + alice ##> "/mr #team bob member" + alice <## "#team: you changed the role of bob from observer to member" + bob <## "#team: alice changed your role from observer to member" + + bob #> "#team hey now" + alice <# "#team bob> hey now" + +testGroupLinkNoContactHostIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g i team" + aliceIncognito <- getTermLine alice + alice <## ("group #team is created, your incognito profile for this group is " <> aliceIncognito) + alice <## "to add members use /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] + alice ##> "/contacts" + bob ##> "/contacts" + + alice ?#> "#team hello" + bob <# ("#team " <> aliceIncognito <> "> hello") + bob #> "#team hi there" + alice ?<# "#team bob> hi there" + +testGroupLinkNoContactInviteeIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactInviteeIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c i " <> gLink) + bobIncognito <- getTermLine bob + bob <## "connection request sent incognito!" + alice <## (bobIncognito <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), + do + bob <## "#team: joining the group..." + bob <## ("#team: you joined the group incognito as " <> bobIncognito) + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] + alice ##> "/contacts" + bob ##> "/contacts" + + alice #> "#team hello" + bob ?<# "#team alice> hello" + bob ?#> "#team hi there" + alice <# ("#team " <> bobIncognito <> "> hi there") + +testGroupLinkNoContactHostProfileReceived :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostProfileReceived = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + let profileImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" + alice ##> ("/set profile image " <> profileImage) + alice <## "profile image updated" + + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + + aliceImage <- getProfilePictureByName bob "alice" + aliceImage `shouldBe` Just profileImage + +testGroupLinkNoContactExistingContactMerged :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactExistingContactMerged = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "#team: bob_1 joined the group" + alice <## "contact and member are merged: bob, #team bob_1" + alice <## "use @bob to send messages", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + bob <## "contact and member are merged: alice, #team alice_1" + bob <## "use @alice to send messages" + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice <##> bob + + alice @@@ [("#team", "connected"), ("@bob", "hey")] + bob @@@ [("#team", "connected"), ("@alice", "hey")] + alice ##> "/contacts" + alice <## "bob (Bob)" + bob ##> "/contacts" + bob <## "alice (Alice)" + + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi there" + alice <# "#team bob> hi there" + +testPlanGroupLinkNoContactKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkNoContactKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect" + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + +testPlanGroupLinkNoContactConnecting :: HasCallStack => FilePath -> IO () +testPlanGroupLinkNoContactConnecting tmp = do + gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + getGroupLink alice "team" GRMember True + withNewTestChat tmp "bob" bobProfile $ \bob -> do + threadDelay 100000 + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting, allowed to reconnect" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting, allowed to reconnect" + + threadDelay 100000 + withTestChat tmp "alice" $ \alice -> do + alice + <### [ "1 group links active", + "#team: group is empty", + "bob (Bob): accepting request to join group #team..." + ] + withTestChat tmp "bob" $ \bob -> do + threadDelay 500000 + bob <## "#team: joining the group..." + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting to group #team" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting to group #team" + + bob ##> ("/c " <> gLink) + bob <## "group link: connecting to group #team" + testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () testGroupMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do @@ -3183,7 +3485,7 @@ testMergeContactMultipleMembers = testMergeGroupLinkHostMultipleContacts :: HasCallStack => FilePath -> IO () testMergeGroupLinkHostMultipleContacts = - testChat2 bobProfile cathProfile $ + testChatCfg2 testCfgGroupLinkViaContact bobProfile cathProfile $ \bob cath -> do connectUsers bob cath @@ -3412,7 +3714,7 @@ testMemberContactInvitedConnectionReplaced tmp = do testMemberContactIncognito :: HasCallStack => FilePath -> IO () testMemberContactIncognito = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do -- create group, bob joins incognito alice ##> "/g team" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index fcb6b65e41..f0f47978b5 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -17,12 +17,14 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T +import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), InlineFilesConfig (..), defaultInlineFilesConfig) import Simplex.Chat.Protocol import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences -import Simplex.Messaging.Agent.Store.SQLite (withTransaction) +import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) @@ -433,6 +435,12 @@ getContactProfiles cc = do profiles <- withTransaction (chatStore $ chatController cc) $ \db -> getUserContactProfiles db user pure $ map (\Profile {displayName} -> displayName) profiles +getProfilePictureByName :: TestCC -> String -> IO (Maybe String) +getProfilePictureByName cc displayName = + withTransaction (chatStore $ chatController cc) $ \db -> + maybeFirstRow fromOnly $ + DB.query db "SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1" (Only displayName) + lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do cc ##> "/last_item_id" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 0b99c5a4d8..f5c1bf8560 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -122,7 +122,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-2\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-3\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -232,13 +232,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.inv" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" @@ -250,7 +250,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" From 42458a2715ffee6a4cd4cd8b30495027d3f283ac Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 31 Oct 2023 10:51:02 +0400 Subject: [PATCH 128/294] ios, android: process new group link events (#3293) --- apps/ios/Shared/Model/SimpleXAPI.swift | 16 ++++++++++++++ apps/ios/SimpleXChat/APITypes.swift | 6 ++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 21 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index de09853e12..3ace6735f1 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1362,6 +1362,12 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateChatInfo(cInfo) } } + case let .groupMemberUpdated(user, groupInfo, _, toMember): + if active(user) { + await MainActor.run { + _ = m.upsertGroupMember(groupInfo, toMember) + } + } case let .contactsMerged(user, intoContact, mergedContact): if active(user) && m.hasChat(mergedContact.id) { await MainActor.run { @@ -1475,6 +1481,16 @@ func processReceivedMsg(_ res: ChatResponse) async { m.removeChat(hostContact.activeConn.id) } } + case let .groupLinkConnecting(user, groupInfo, hostMember): + if !active(user) { return } + + await MainActor.run { + m.updateGroup(groupInfo) + if let hostConn = hostMember.activeConn { + m.dismissConnReqView(hostConn.id) + m.removeChat(hostConn.id) + } + } case let .joinedGroupMemberConnecting(user, groupInfo, _, member): if active(user) { await MainActor.run { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index e270674784..4afe2583ca 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -496,6 +496,7 @@ public enum ChatResponse: Decodable, Error { case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef) case contactUpdated(user: UserRef, toContact: Contact) + case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) // TODO remove events below case contactsSubscribed(server: String, contactRefs: [ContactRef]) case contactsDisconnected(server: String, contactRefs: [ContactRef]) @@ -518,6 +519,7 @@ public enum ChatResponse: Decodable, Error { case groupCreated(user: UserRef, groupInfo: GroupInfo) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) + case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: Group) @@ -638,6 +640,7 @@ public enum ChatResponse: Decodable, Error { case .acceptingContactRequest: return "acceptingContactRequest" case .contactRequestRejected: return "contactRequestRejected" case .contactUpdated: return "contactUpdated" + case .groupMemberUpdated: return "groupMemberUpdated" case .contactsSubscribed: return "contactsSubscribed" case .contactsDisconnected: return "contactsDisconnected" case .contactSubSummary: return "contactSubSummary" @@ -657,6 +660,7 @@ public enum ChatResponse: Decodable, Error { case .groupCreated: return "groupCreated" case .sentGroupInvitation: return "sentGroupInvitation" case .userAcceptedGroupSent: return "userAcceptedGroupSent" + case .groupLinkConnecting: return "groupLinkConnecting" case .userDeletedMember: return "userDeletedMember" case .leftMemberUser: return "leftMemberUser" case .groupMembers: return "groupMembers" @@ -777,6 +781,7 @@ public enum ChatResponse: Decodable, Error { case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case .contactRequestRejected: return noDetails case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) @@ -796,6 +801,7 @@ public enum ChatResponse: Decodable, Error { case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index b751cb56c5..7ce508f662 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1443,6 +1443,11 @@ object ChatController { chatModel.updateChatInfo(cInfo) } } + is CR.GroupMemberUpdated -> { + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.toMember) + } + } is CR.ContactsMerged -> { if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { @@ -1553,6 +1558,16 @@ object ChatController { chatModel.removeChat(r.hostContact.activeConn.id) } } + is CR.GroupLinkConnecting -> { + if (!active(r.user)) return + + chatModel.updateGroup(r.groupInfo) + val hostConn = r.hostMember.activeConn + if (hostConn != null) { + chatModel.dismissConnReqView(hostConn.id) + chatModel.removeChat(hostConn.id) + } + } is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { chatModel.upsertGroupMember(r.groupInfo, r.member) @@ -3379,6 +3394,7 @@ sealed class CR { @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() + @Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR() // TODO remove below @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() @@ -3401,6 +3417,7 @@ sealed class CR { @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() + @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @@ -3515,6 +3532,7 @@ sealed class CR { is AcceptingContactRequest -> "acceptingContactRequest" is ContactRequestRejected -> "contactRequestRejected" is ContactUpdated -> "contactUpdated" + is GroupMemberUpdated -> "groupMemberUpdated" is ContactsSubscribed -> "contactsSubscribed" is ContactsDisconnected -> "contactsDisconnected" is ContactSubSummary -> "contactSubSummary" @@ -3534,6 +3552,7 @@ sealed class CR { is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" + is GroupLinkConnecting -> "groupLinkConnecting" is UserDeletedMember -> "userDeletedMember" is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" @@ -3646,6 +3665,7 @@ sealed class CR { is AcceptingContactRequest -> withUser(user, json.encodeToString(contact)) is ContactRequestRejected -> withUser(user, noDetails()) is ContactUpdated -> withUser(user, json.encodeToString(toContact)) + is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember") is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) @@ -3665,6 +3685,7 @@ sealed class CR { is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) + is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) From 07173f7b2f45df4213f4c0573fc7026d4cb623f2 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 31 Oct 2023 10:51:20 +0400 Subject: [PATCH 129/294] core: add delays to testXFTPMarkToReceive test (#3294) --- tests/ChatTests/Files.hs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 5231283903..03a5d6acfb 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -1393,10 +1393,16 @@ testXFTPMarkToReceive = do alice <## "completed uploading file 1 (test.pdf) for bob" bob #$> ("/_set_file_to_receive 1", id, "ok") + threadDelay 100000 + bob ##> "/_stop" bob <## "chat stopped" + bob #$> ("/_files_folder ./tests/tmp/bob_files", id, "ok") bob #$> ("/_temp_folder ./tests/tmp/bob_xftp", id, "ok") + + threadDelay 100000 + bob ##> "/_start" bob <## "chat started" From 9e8084874fd2252ff2ecdab0fff1d767f5bac9d1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 31 Oct 2023 09:44:57 +0000 Subject: [PATCH 130/294] ios: block members (#3248) * ios: block members (WIP) * CIBlocked, blocking api * show item as blocked * show blocked and merge multiple deleted items * update block icons * split sent and received deleted to two categories * merge chat feature items, refactor CIMergedRange * merge feature items, two profile images and names on merged items * ensure range is withing chat items range * merge group events * fix/refactor * make group member changes observable * exclude some group events from merging * fix states not updating and other fixes * load list of members when tapping profile * refactor * fix incorrect merging of sent/received marked deleted * fix incorrect expand/hide on single moderated items without content * load members list when opening member via item * comments * fix member counting in case of name collision --- apps/ios/Shared/AppDelegate.swift | 1 - apps/ios/Shared/Model/ChatModel.swift | 100 +++- apps/ios/Shared/Model/SimpleXAPI.swift | 11 +- .../Shared/Views/Call/CallController.swift | 2 - .../Views/Chat/ChatItem/CICallItemView.swift | 4 +- .../Chat/ChatItem/CIChatFeatureView.swift | 78 ++- .../Views/Chat/ChatItem/CIEventView.swift | 8 +- .../ChatItem/CIFeaturePreferenceView.swift | 5 +- .../Views/Chat/ChatItem/CIFileView.swift | 24 +- .../Views/Chat/ChatItem/CIImageView.swift | 3 +- .../ChatItem/CIMemberCreatedContactView.swift | 3 +- .../Views/Chat/ChatItem/CIMetaView.swift | 17 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 17 +- .../Views/Chat/ChatItem/CIVideoView.swift | 9 +- .../Views/Chat/ChatItem/CIVoiceView.swift | 15 +- .../Views/Chat/ChatItem/DeletedItemView.swift | 7 +- .../Views/Chat/ChatItem/EmojiItemView.swift | 7 +- .../Chat/ChatItem/FramedCIVoiceView.swift | 11 +- .../Views/Chat/ChatItem/FramedItemView.swift | 80 +-- .../Chat/ChatItem/FullScreenMediaView.swift | 2 +- .../ChatItem/IntegrityErrorItemView.swift | 8 +- .../Chat/ChatItem/MarkedDeletedItemView.swift | 65 ++- .../Views/Chat/ChatItem/MsgContentView.swift | 3 +- .../Shared/Views/Chat/ChatItemInfoView.swift | 5 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 123 +++-- apps/ios/Shared/Views/Chat/ChatView.swift | 470 ++++++++++++------ .../Chat/ComposeMessage/ComposeView.swift | 2 + .../Chat/ComposeMessage/ContextItemView.swift | 4 +- .../Chat/Group/AddGroupMembersView.swift | 2 +- .../Views/Chat/Group/GroupChatInfoView.swift | 174 +++++-- .../Chat/Group/GroupMemberInfoView.swift | 118 ++++- .../Shared/Views/ChatList/ChatListView.swift | 2 +- .../ChatList/ContactConnectionInfo.swift | 2 +- .../Views/Helpers/VideoPlayerView.swift | 1 - .../Shared/Views/NewChat/AddContactView.swift | 2 +- .../Shared/Views/NewChat/AddGroupView.swift | 2 +- .../Views/UserSettings/PrivacySettings.swift | 2 +- apps/ios/SimpleXChat/APITypes.swift | 3 + apps/ios/SimpleXChat/ChatTypes.swift | 56 ++- 39 files changed, 1001 insertions(+), 447 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 01afa18a15..39f5df636c 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -55,7 +55,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { logger.debug("AppDelegate: didReceiveRemoteNotification") - print("*** userInfo", userInfo) let m = ChatModel.shared if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], m.notificationMode != .off { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3cc52d502d..f562ea7f51 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -64,7 +64,7 @@ final class ChatModel: ObservableObject { @Published var reversedChatItems: [ChatItem] = [] var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? - @Published var groupMembers: [GroupMember] = [] + @Published var groupMembers: [GMember] = [] // items in the terminal view @Published var showingTerminal = false @Published var terminalItems: [TerminalItem] = [] @@ -163,6 +163,10 @@ final class ChatModel: ObservableObject { } } + func getGroupMember(_ groupMemberId: Int64) -> GMember? { + groupMembers.first { $0.groupMemberId == groupMemberId } + } + private func getChatIndex(_ id: String) -> Int? { chats.firstIndex(where: { $0.id == id }) } @@ -176,6 +180,7 @@ final class ChatModel: ObservableObject { func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { chats[i].chatInfo = cInfo + chats[i].created = Date.now } } @@ -339,7 +344,7 @@ final class ChatModel: ObservableObject { reversedChatItems[i].viewTimestamp = .now } - private func getChatItemIndex(_ cItem: ChatItem) -> Int? { + func getChatItemIndex(_ cItem: ChatItem) -> Int? { reversedChatItems.firstIndex(where: { $0.id == cItem.id }) } @@ -528,27 +533,62 @@ final class ChatModel: ObservableObject { users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) } - func getConnectedMemberNames(_ ci: ChatItem) -> [String] { - guard var i = getChatItemIndex(ci) else { return [] } + // this function analyses "connected" events and assumes that each member will be there only once + func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) { + var count = 0 var ns: [String] = [] - while i < reversedChatItems.count, let m = reversedChatItems[i].memberConnected { - ns.append(m.displayName) - i += 1 + if let ciCategory = chatItem.mergeCategory, + var i = getChatItemIndex(chatItem) { + while i < reversedChatItems.count { + let ci = reversedChatItems[i] + if ci.mergeCategory != ciCategory { break } + if let m = ci.memberConnected { + ns.append(m.displayName) + } + count += 1 + i += 1 + } } - return ns + return (count, ns) } - func getChatItemNeighbors(_ ci: ChatItem) -> (ChatItem?, ChatItem?) { - if let i = getChatItemIndex(ci) { - return ( - i + 1 < reversedChatItems.count ? reversedChatItems[i + 1] : nil, - i - 1 >= 0 ? reversedChatItems[i - 1] : nil - ) + // returns the index of the passed item and the next item (it has smaller index) + func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) { + if let i = getChatItemIndex(ci) { + (i, i > 0 ? reversedChatItems[i - 1] : nil) } else { - return (nil, nil) + (nil, nil) } } + // returns the index of the first item in the same merged group (the first hidden item) + // and the previous visible item with another merge category + func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) { + guard var i = ciIndex else { return (nil, nil) } + let fst = reversedChatItems.count - 1 + while i < fst { + i = i + 1 + let ci = reversedChatItems[i] + if ciCategory == nil || ciCategory != ci.mergeCategory { + return (i - 1, ci) + } + } + return (i, nil) + } + + // returns the previous member in the same merge group and the count of members in this group + func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange) -> (GroupMember?, Int) { + var prevMember: GroupMember? = nil + var memberIds: Set = [] + for i in range { + if case let .groupRcv(m) = reversedChatItems[i].chatDir { + if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } + memberIds.insert(m.groupMemberId) + } + } + return (prevMember, memberIds.count) + } + func popChat(_ id: String) { if let i = getChatIndex(id) { popChat_(i) @@ -583,13 +623,14 @@ final class ChatModel: ObservableObject { } // update current chat if chatId == groupInfo.id { - if let i = groupMembers.firstIndex(where: { $0.id == member.id }) { + if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) { withAnimation(.default) { - self.groupMembers[i] = member + self.groupMembers[i].wrapped = member + self.groupMembers[i].created = Date.now } return false } else { - withAnimation { groupMembers.append(member) } + withAnimation { groupMembers.append(GMember(member)) } return true } } else { @@ -598,11 +639,10 @@ final class ChatModel: ObservableObject { } func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) { - if let conn = member.activeConn { - var updatedConn = conn - updatedConn.connectionStats = connectionStats + if var conn = member.activeConn { + conn.connectionStats = connectionStats var updatedMember = member - updatedMember.activeConn = updatedConn + updatedMember.activeConn = conn _ = upsertGroupMember(groupInfo, updatedMember) } } @@ -700,3 +740,19 @@ final class Chat: ObservableObject, Identifiable { public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } + +final class GMember: ObservableObject, Identifiable { + @Published var wrapped: GroupMember + var created = Date.now + + init(_ member: GroupMember) { + self.wrapped = member + } + + var id: String { wrapped.id } + var groupId: Int64 { wrapped.groupId } + var groupMemberId: Int64 { wrapped.groupMemberId } + var displayName: String { wrapped.displayName } + var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } } + static let sampleData = GMember(GroupMember.sampleData) +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 3ace6735f1..32e983a841 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -502,6 +502,10 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings)) } +func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSettings: GroupMemberSettings) async throws { + try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings)) +} + func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } @@ -1079,8 +1083,8 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] { return [] } -func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] { - let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil } +func filterMembersToAdd(_ ms: [GMember]) -> [Contact] { + let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil } return ChatModel.shared.chats .compactMap{ $0.chatInfo.contact } .filter{ !memberContactIds.contains($0.apiId) } @@ -1550,10 +1554,11 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateGroup(toGroup) } } - case let .memberRole(user, groupInfo, _, _, _, _): + case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _): if active(user) { await MainActor.run { m.updateGroup(groupInfo) + _ = m.upsertGroupMember(groupInfo, member) } } case let .newMemberContactReceivedInv(user, contact, _, _): diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 6a20eee59a..9ca894ea89 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -108,7 +108,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse try audioSession.setActive(true) logger.debug("audioSession activated") } catch { - print(error) logger.error("failed activating audio session") } } @@ -121,7 +120,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse try audioSession.setActive(false) logger.debug("audioSession deactivated") } catch { - print(error) logger.error("failed deactivating audio session") } suspendOnEndCall() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 28740a8cf4..f0bf43dffe 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -11,7 +11,7 @@ import SimpleXChat struct CICallItemView: View { @EnvironmentObject var m: ChatModel - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem var status: CICallStatus var duration: Int @@ -60,7 +60,7 @@ struct CICallItemView: View { @ViewBuilder private func acceptCallButton() -> some View { - if case let .direct(contact) = chatInfo { + if case let .direct(contact) = chat.chatInfo { Button { if let invitation = m.callInvitations[contact.id] { CallController.shared.answerCall(invitation: invitation) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index a7bff2f421..03afa30331 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -10,20 +10,92 @@ import SwiftUI import SimpleXChat struct CIChatFeatureView: View { + @EnvironmentObject var m: ChatModel var chatItem: ChatItem + @Binding var revealed: Bool var feature: Feature var icon: String? = nil var iconColor: Color var body: some View { + if !revealed, let fs = mergedFeautures() { + HStack { + ForEach(fs, content: featureIconView) + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + } else { + fullFeatureView + } + } + + private struct FeatureInfo: Identifiable { + var icon: String + var scale: CGFloat + var color: Color + var param: String? + + init(_ f: Feature, _ color: Color, _ param: Int?) { + self.icon = f.iconFilled + self.scale = f.iconScale + self.color = color + self.param = f.hasParam && param != nil ? timeText(param) : nil + } + + var id: String { + "\(icon) \(color) \(param ?? "")" + } + } + + private func mergedFeautures() -> [FeatureInfo]? { + var fs: [FeatureInfo] = [] + var icons: Set = [] + if var i = m.getChatItemIndex(chatItem) { + while i < m.reversedChatItems.count, + let f = featureInfo(m.reversedChatItems[i]) { + if !icons.contains(f.icon) { + fs.insert(f, at: 0) + icons.insert(f.icon) + } + i += 1 + } + } + return fs.count > 1 ? fs : nil + } + + private func featureInfo(_ ci: ChatItem) -> FeatureInfo? { + switch ci.content { + case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) + case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) + case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) + case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) + default: nil + } + } + + @ViewBuilder private func featureIconView(_ f: FeatureInfo) -> some View { + let i = Image(systemName: f.icon) + .foregroundColor(f.color) + .scaleEffect(f.scale) + if let param = f.param { + HStack { + i + chatEventText(Text(param)).lineLimit(1) + } + } else { + i + } + } + + private var fullFeatureView: some View { HStack(alignment: .bottom, spacing: 4) { Image(systemName: icon ?? feature.iconFilled) .foregroundColor(iconColor) .scaleEffect(feature.iconScale) chatEventText(chatItem) } - .padding(.leading, 6) - .padding(.bottom, 6) + .padding(.horizontal, 6) + .padding(.vertical, 4) .textSelection(.disabled) } } @@ -31,6 +103,6 @@ struct CIChatFeatureView: View { struct CIChatFeatureView_Previews: PreviewProvider { static var previews: some View { let enabled = FeatureEnabled(forUser: false, forContact: false) - CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) + CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift index 9b372154ed..b6be3f837e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift @@ -13,11 +13,9 @@ struct CIEventView: View { var eventText: Text var body: some View { - HStack(alignment: .bottom, spacing: 0) { - eventText - } - .padding(.leading, 6) - .padding(.bottom, 6) + eventText + .padding(.horizontal, 6) + .padding(.vertical, 4) .textSelection(.disabled) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index bb38cc872a..e52a92a3c6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat struct CIFeaturePreferenceView: View { - @EnvironmentObject var chat: Chat + @ObservedObject var chat: Chat var chatItem: ChatItem var feature: ChatFeature var allowed: FeatureAllowed @@ -80,7 +80,6 @@ struct CIFeaturePreferenceView_Previews: PreviewProvider { quotedItem: nil, file: nil ) - CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30) - .environmentObject(Chat.sampleData) + CIFeaturePreferenceView(chat: Chat.sampleData, chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 359633a5f5..4ae2296f46 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIFileView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme let file: CIFile? let edited: Bool @@ -83,7 +84,7 @@ struct CIFileView: View { if fileSizeValid() { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { let encrypted = privacyEncryptLocalFilesGroupDefault.get() await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted) } @@ -234,18 +235,17 @@ struct CIFileView_Previews: PreviewProvider { file: nil ) Group { - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 360)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index bb43179577..9ae52ae01b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIImageView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme let chatItem: ChatItem let image: String @@ -36,7 +37,7 @@ struct CIImageView: View { switch file.fileStatus { case .rcvInvitation: Task { - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index a204d83f1e..da82ed4dd2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIMemberCreatedContactView: View { + @EnvironmentObject var m: ChatModel var chatItem: ChatItem var body: some View { @@ -21,7 +22,7 @@ struct CIMemberCreatedContactView: View { .onTapGesture { dismissAllSheets(animated: true) DispatchQueue.main.async { - ChatModel.shared.chatId = "@\(contactId)" + m.chatId = "@\(contactId)" } } } else { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 30430dc19a..c189abde24 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat struct CIMetaView: View { - @EnvironmentObject var chat: Chat + @ObservedObject var chat: Chat var chatItem: ChatItem var metaColor = Color.secondary var paleMetaColor = Color(UIColor.tertiaryLabel) @@ -95,15 +95,14 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text { struct CIMetaView_Previews: PreviewProvider { static var previews: some View { Group { - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true)) - CIMetaView(chatItem: ChatItem.getDeletedContentSample()) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true)) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) } .previewLayout(.fixed(width: 360, height: 100)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index e1a5c252ec..f276025dda 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -12,7 +12,8 @@ import SimpleXChat let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup." struct CIRcvDecryptionError: View { - @EnvironmentObject var chat: Chat + @EnvironmentObject var m: ChatModel + @ObservedObject var chat: Chat var msgDecryptError: MsgDecryptError var msgCount: UInt32 var chatItem: ChatItem @@ -45,7 +46,7 @@ struct CIRcvDecryptionError: View { do { let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId) if let s = stats { - ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s) + m.updateGroupMemberConnectionStats(groupInfo, member, s) } } catch let error { logger.error("apiGroupMemberInfo error: \(responseError(error))") @@ -79,8 +80,8 @@ struct CIRcvDecryptionError: View { } } else if case let .group(groupInfo) = chat.chatInfo, case let .groupRcv(groupMember) = chatItem.chatDir, - let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }), - let memberStats = modelMember.activeConn?.connectionStats { + let mem = m.getGroupMember(groupMember.groupMemberId), + let memberStats = mem.wrapped.activeConn?.connectionStats { if memberStats.ratchetSyncAllowed { decryptionErrorItemFixButton(syncSupported: true) { alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) } @@ -122,7 +123,7 @@ struct CIRcvDecryptionError: View { ) } .padding(.horizontal, 12) - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .onTapGesture(perform: { onClick() }) @@ -142,7 +143,7 @@ struct CIRcvDecryptionError: View { + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) } .padding(.horizontal, 12) - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .onTapGesture(perform: { onClick() }) @@ -173,7 +174,7 @@ struct CIRcvDecryptionError: View { do { let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false) await MainActor.run { - ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats) + m.updateGroupMemberConnectionStats(groupInfo, mem, stats) } } catch let error { logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))") @@ -190,7 +191,7 @@ struct CIRcvDecryptionError: View { do { let stats = try apiSyncContactRatchet(contact.apiId, false) await MainActor.run { - ChatModel.shared.updateContactConnectionStats(contact, stats) + m.updateContactConnectionStats(contact, stats) } } catch let error { logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 3807a11b4e..bc7153ed47 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -11,6 +11,7 @@ import AVKit import SimpleXChat struct CIVideoView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme private let chatItem: ChatItem private let image: String @@ -101,7 +102,7 @@ struct CIVideoView: View { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete VideoPlayerView(player: player, url: url, showControls: false) .frame(width: w, height: w * preview.size.height / preview.size.width) - .onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in + .onChange(of: m.stopPreviousRecPlay) { playingUrl in if playingUrl != url { player.pause() videoPlaying = false @@ -124,7 +125,7 @@ struct CIVideoView: View { } if !videoPlaying { Button { - ChatModel.shared.stopPreviousRecPlay = url + m.stopPreviousRecPlay = url player.play() } label: { playPauseIcon(canBePlayed ? "play.fill" : "play.slash") @@ -256,7 +257,7 @@ struct CIVideoView: View { // TODO encrypt: where file size is checked? private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { Task { - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { await receiveFile(user, file.fileId, encrypted, false) } } @@ -290,7 +291,7 @@ struct CIVideoView: View { ) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { - ChatModel.shared.stopPreviousRecPlay = url + m.stopPreviousRecPlay = url if let player = fullPlayer { player.play() fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index b0875abe8d..2e54ba4143 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIVoiceView: View { + @ObservedObject var chat: Chat var chatItem: ChatItem let recordingFile: CIFile? let duration: Int @@ -91,7 +92,7 @@ struct CIVoiceView: View { } private func metaView() -> some View { - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) } } @@ -219,7 +220,7 @@ struct VoiceMessagePlayer: View { private func downloadButton(_ recordingFile: CIFile) -> some View { Button { Task { - if let user = ChatModel.shared.currentUser { + if let user = chatModel.currentUser { await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get()) } } @@ -284,6 +285,7 @@ struct CIVoiceView_Previews: PreviewProvider { ) Group { CIVoiceView( + chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), duration: 30, @@ -292,12 +294,11 @@ struct CIVoiceView_Previews: PreviewProvider { playbackTime: .constant(TimeInterval(20)), allowMenu: Binding.constant(true) ) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 360)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index af4df40978..4763707421 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct DeletedItemView: View { @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat var chatItem: ChatItem var body: some View { @@ -18,7 +19,7 @@ struct DeletedItemView: View { Text(chatItem.content.text) .foregroundColor(.secondary) .italic() - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .padding(.leading, 12) @@ -32,8 +33,8 @@ struct DeletedItemView: View { struct DeletedItemView_Previews: PreviewProvider { static var previews: some View { Group { - DeletedItemView(chatItem: ChatItem.getDeletedContentSample()) - DeletedItemView(chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData))) + DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) + DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData))) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index f5ae761e84..f57e45fed0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct EmojiItemView: View { + @ObservedObject var chat: Chat var chatItem: ChatItem var body: some View { @@ -17,7 +18,7 @@ struct EmojiItemView: View { emojiText(chatItem.content.text) .padding(.top, 8) .padding(.horizontal, 6) - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.bottom, 8) .padding(.horizontal, 12) } @@ -32,8 +33,8 @@ func emojiText(_ text: String) -> Text { struct EmojiItemView_Previews: PreviewProvider { static var previews: some View { Group{ - EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete))) - EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) + EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete))) + EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) } .previewLayout(.fixed(width: 360, height: 70)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 3f7ca3f836..af5c917dc8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -88,13 +88,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider { file: CIFile.getSample(fileStatus: .sndComplete) ) Group { - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 360)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index aab0cd5f55..51dfa3cb50 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -15,9 +15,11 @@ private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09) struct FramedItemView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem + @Binding var revealed: Bool var maxWidth: CGFloat = .infinity @State var scrollProxy: ScrollViewProxy? = nil @State var msgWidth: CGFloat = 0 @@ -35,9 +37,12 @@ struct FramedItemView: View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { if let di = chatItem.meta.itemDeleted { - if case let .moderated(_, byGroupMember) = di { - framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.chatViewName)").italic()) - } else { + switch di { + case let .moderated(_, byGroupMember): + framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic()) + case .blocked: + framedItemHeader(icon: "hand.raised", caption: Text("blocked").italic()) + default: framedItemHeader(icon: "trash", caption: Text("marked deleted").italic()) } } else if chatItem.meta.isLive { @@ -48,7 +53,7 @@ struct FramedItemView: View { ciQuoteView(qi) .onTapGesture { if let proxy = scrollProxy, - let ci = ChatModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { + let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { proxy.scrollTo(ci.viewId, anchor: .bottom) } @@ -56,14 +61,14 @@ struct FramedItemView: View { } } - ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: framedMsgContentView) + ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: framedMsgContentView) .padding(chatItem.content.msgContent != nil ? 0 : 4) .overlay(DetermineWidth()) } .onPreferenceChange(MetaColorPreferenceKey.self) { metaColor = $0 } if chatItem.content.msgContent != nil { - CIMetaView(chatItem: chatItem, metaColor: metaColor) + CIMetaView(chat: chat, chatItem: chatItem, metaColor: metaColor) .padding(.horizontal, 12) .padding(.bottom, 6) .overlay(DetermineWidth()) @@ -247,7 +252,7 @@ struct FramedItemView: View { } private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View { - MsgContentView(text: qi.text, formattedText: qi.formattedText) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText) .lineLimit(lines) .font(.subheadline) .padding(.bottom, 6) @@ -264,7 +269,7 @@ struct FramedItemView: View { } private func membership() -> GroupMember? { - switch chatInfo { + switch chat.chatInfo { case let .group(groupInfo: groupInfo): return groupInfo.membership default: return nil } @@ -274,6 +279,7 @@ struct FramedItemView: View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let v = MsgContentView( + chat: chat, text: text, formattedText: text == "" ? [] : ci.formattedText, meta: ci.meta, @@ -356,14 +362,14 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Co struct FramedItemView_Previews: PreviewProvider { static var previews: some View { Group{ - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -372,16 +378,16 @@ struct FramedItemView_Previews: PreviewProvider { struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -390,16 +396,16 @@ struct FramedItemView_Edited_Previews: PreviewProvider { struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index be5b61b61a..0e721acdcb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -150,7 +150,7 @@ struct FullScreenMediaView: View { private func startPlayerAndNotify() { if let player = player { - ChatModel.shared.stopPreviousRecPlay = url + m.stopPreviousRecPlay = url player.play() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index 9908d4d104..1aa0093c9a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -10,11 +10,12 @@ import SwiftUI import SimpleXChat struct IntegrityErrorItemView: View { + @ObservedObject var chat: Chat var msgError: MsgErrorType var chatItem: ChatItem var body: some View { - CIMsgError(chatItem: chatItem) { + CIMsgError(chat: chat, chatItem: chatItem) { switch msgError { case .msgSkipped: AlertManager.shared.showAlertMsg( @@ -52,6 +53,7 @@ struct IntegrityErrorItemView: View { } struct CIMsgError: View { + @ObservedObject var chat: Chat var chatItem: ChatItem var onTap: () -> Void @@ -60,7 +62,7 @@ struct CIMsgError: View { Text(chatItem.content.text) .foregroundColor(.red) .italic() - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .padding(.leading, 12) @@ -74,6 +76,6 @@ struct CIMsgError: View { struct IntegrityErrorItemView_Previews: PreviewProvider { static var previews: some View { - IntegrityErrorItemView(msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) + IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index 8e042a8c76..c6af95e6f6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -10,39 +10,70 @@ import SwiftUI import SimpleXChat struct MarkedDeletedItemView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat var chatItem: ChatItem + @Binding var revealed: Bool var body: some View { - HStack(alignment: .bottom, spacing: 0) { - if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted { - markedDeletedText("moderated by \(byGroupMember.chatViewName)") - } else { - markedDeletedText("marked deleted") - } - CIMetaView(chatItem: chatItem) - .padding(.horizontal, 12) - } - .padding(.leading, 12) + (Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 12) .padding(.vertical, 6) .background(chatItemFrameColor(chatItem, colorScheme)) .cornerRadius(18) .textSelection(.disabled) } - func markedDeletedText(_ s: LocalizedStringKey) -> some View { - Text(s) - .font(.caption) - .foregroundColor(.secondary) - .italic() - .lineLimit(1) + var mergedMarkedDeletedText: LocalizedStringKey { + if !revealed, + let ciCategory = chatItem.mergeCategory, + var i = m.getChatItemIndex(chatItem) { + var moderated = 0 + var blocked = 0 + var deleted = 0 + var moderatedBy: Set = [] + while i < m.reversedChatItems.count, + let ci = .some(m.reversedChatItems[i]), + ci.mergeCategory == ciCategory, + let itemDeleted = ci.meta.itemDeleted { + switch itemDeleted { + case let .moderated(_, byGroupMember): + moderated += 1 + moderatedBy.insert(byGroupMember.displayName) + case .blocked: blocked += 1 + case .deleted: deleted += 1 + } + i += 1 + } + let total = moderated + blocked + deleted + return total <= 1 + ? markedDeletedText + : total == moderated + ? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))" + : total == blocked + ? "\(total) messages blocked" + : "\(total) messages marked deleted" + } else { + return markedDeletedText + } + } + + var markedDeletedText: LocalizedStringKey { + switch chatItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" + case .blocked: "blocked" + default: "marked deleted" + } } } struct MarkedDeletedItemView_Previews: PreviewProvider { static var previews: some View { Group { - MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))) + MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 8b757ed1a3..d0d2bdf3dd 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -25,7 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text { } struct MsgContentView: View { - @EnvironmentObject var chat: Chat + @ObservedObject var chat: Chat var text: String var formattedText: [FormattedText]? = nil var sender: String? = nil @@ -152,6 +152,7 @@ struct MsgContentView_Previews: PreviewProvider { static var previews: some View { let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") return MsgContentView( + chat: Chat.sampleData, text: chatItem.text, formattedText: chatItem.formattedText, sender: chatItem.memberDisplayName, diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 61802c61fe..83c4cdcda6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct ChatItemInfoView: View { + @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme var ci: ChatItem @Binding var chatItemInfo: ChatItemInfo? @@ -290,8 +291,8 @@ struct ChatItemInfoView: View { private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] { memberDeliveryStatuses.compactMap({ mds in - if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) { - return (mem, mds.memberDeliveryStatus) + if let mem = chatModel.getGroupMember(mds.groupMemberId) { + return (mem.wrapped, mds.memberDeliveryStatus) } else { return nil } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 31fe19c39a..657df60654 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat struct ChatItemView: View { - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem var maxWidth: CGFloat = .infinity @State var scrollProxy: ScrollViewProxy? = nil @@ -19,8 +19,19 @@ struct ChatItemView: View { @Binding var audioPlayer: AudioPlayer? @Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackTime: TimeInterval? - init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding, allowMenu: Binding = .constant(false), audioPlayer: Binding = .constant(nil), playbackState: Binding = .constant(.noPlayback), playbackTime: Binding = .constant(nil)) { - self.chatInfo = chatInfo + init( + chat: Chat, + chatItem: ChatItem, + showMember: Bool = false, + maxWidth: CGFloat = .infinity, + scrollProxy: ScrollViewProxy? = nil, + revealed: Binding, + allowMenu: Binding = .constant(false), + audioPlayer: Binding = .constant(nil), + playbackState: Binding = .constant(.noPlayback), + playbackTime: Binding = .constant(nil) + ) { + self.chat = chat self.chatItem = chatItem self.maxWidth = maxWidth _scrollProxy = .init(initialValue: scrollProxy) @@ -33,15 +44,15 @@ struct ChatItemView: View { var body: some View { let ci = chatItem - if chatItem.meta.itemDeleted != nil && !revealed { - MarkedDeletedItemView(chatItem: chatItem) + if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { + MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed) } else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { - EmojiItemView(chatItem: ci) + EmojiItemView(chat: chat, chatItem: ci) } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { - CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu) + CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu) } else if ci.content.msgContent == nil { - ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case + ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case } else { framedItemView() } @@ -51,14 +62,15 @@ struct ChatItemView: View { } private func framedItemView() -> some View { - FramedItemView(chatInfo: chatInfo, chatItem: chatItem, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) + FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) } } struct ChatItemContentView: View { @EnvironmentObject var chatModel: ChatModel - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem + @Binding var revealed: Bool var msgContentView: () -> Content @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -72,15 +84,14 @@ struct ChatItemContentView: View { case let .rcvCall(status, duration): callItemView(status, duration) case let .rcvIntegrityError(msgError): if developerTools { - IntegrityErrorItemView(msgError: msgError, chatItem: chatItem) + IntegrityErrorItemView(chat: chat, msgError: msgError, chatItem: chatItem) } else { ZStack {} } - case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) + case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case .rcvDirectEvent: eventItemView() - case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText) case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) case .rcvGroupEvent: eventItemView() case .sndGroupEvent: eventItemView() @@ -89,9 +100,9 @@ struct ChatItemContentView: View { case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .rcvChatPreference(feature, allowed, param): - CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param) + CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) case let .sndChatPreference(feature, _, _): - CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary) + CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary) case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) @@ -103,15 +114,15 @@ struct ChatItemContentView: View { } private func deletedItemView() -> some View { - DeletedItemView(chatItem: chatItem) + DeletedItemView(chat: chat, chatItem: chatItem) } private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View { - CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration) + CICallItemView(chat: chat, chatItem: chatItem, status: status, duration: duration) } private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View { - CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chatInfo.incognito) + CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito) } private func eventItemView() -> some View { @@ -119,7 +130,9 @@ struct ChatItemContentView: View { } private func eventItemViewText() -> Text { - if let member = chatItem.memberDisplayName { + if !revealed, let t = mergedGroupEventText { + return chatEventText(t + Text(" ") + chatItem.timestampText) + } else if let member = chatItem.memberDisplayName { return Text(member + " ") .font(.caption) .foregroundColor(.secondary) @@ -131,36 +144,44 @@ struct ChatItemContentView: View { } private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { - CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor) + CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor) } - private var membersConnectedItemText: Text { - if let t = membersConnectedText { - return chatEventText(t, chatItem.timestampText) + private var mergedGroupEventText: Text? { + let (count, ns) = chatModel.getConnectedMemberNames(chatItem) + let members: LocalizedStringKey = + switch ns.count { + case 1: "\(ns[0]) connected" + case 2: "\(ns[0]) and \(ns[1]) connected" + case 3: "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected" + default: + ns.count > 3 + ? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected" + : "" + } + return if count <= 1 { + nil + } else if ns.count == 0 { + Text("\(count) group events") + } else if count > ns.count { + Text(members) + Text(" ") + Text("and \(count - ns.count) other events") } else { - return eventItemViewText() + Text(members) } } - - private var membersConnectedText: LocalizedStringKey? { - let ns = chatModel.getConnectedMemberNames(chatItem) - return ns.count > 3 - ? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected" - : ns.count == 3 - ? "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected" - : ns.count == 2 - ? "\(ns[0]) and \(ns[1]) connected" - : nil - } } -func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text { - (Text(eventText) + Text(" ") + ts) +func chatEventText(_ text: Text) -> Text { + text .font(.caption) .foregroundColor(.secondary) .fontWeight(.light) } +func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text { + chatEventText(Text(eventText) + Text(" ") + ts) +} + func chatEventText(_ ci: ChatItem) -> Text { chatEventText("\(ci.content.text)", ci.timestampText) } @@ -168,15 +189,15 @@ func chatEventText(_ ci: ChatItem) -> Text { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 70)) .environmentObject(Chat.sampleData) @@ -188,7 +209,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) Group{ ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -199,7 +220,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead), @@ -210,7 +231,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -221,7 +242,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -232,7 +253,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)), diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5679b451a0..5e5a7f8b51 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -21,9 +21,7 @@ struct ChatView: View { @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() - @State private var deletingItem: ChatItem? = nil @State private var keyboardVisible = false - @State private var showDeleteMessage = false @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? @@ -36,7 +34,8 @@ struct ChatView: View { @State private var searchText: String = "" @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon - @State private var selectedMember: GroupMember? = nil + @State private var membersLoaded = false + @State private var selectedMember: GMember? = nil // opening GroupLinkView on link button (incognito) @State private var showGroupLinkSheet: Bool = false @State private var groupLink: String? @@ -97,6 +96,8 @@ struct ChatView: View { if chatModel.chatId == nil { chatModel.chatItemStatuses = [:] chatModel.reversedChatItems = [] + chatModel.groupMembers = [] + membersLoaded = false } } } @@ -134,18 +135,21 @@ struct ChatView: View { } } else if case let .group(groupInfo) = cInfo { Button { - Task { - let groupMembers = await apiListMembers(groupInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - showChatInfoSheet = true - } - } + Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } } } label: { ChatInfoToolbar(chat: chat) } .appSheet(isPresented: $showChatInfoSheet) { - GroupChatInfoView(chat: chat, groupInfo: groupInfo) + GroupChatInfoView( + chat: chat, + groupInfo: Binding( + get: { groupInfo }, + set: { gInfo in + chat.chatInfo = .group(groupInfo: gInfo) + chat.created = Date.now + } + ) + ) } } } @@ -208,6 +212,17 @@ struct ChatView: View { } } + private func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async { + let groupMembers = await apiListMembers(groupInfo.groupId) + await MainActor.run { + if chatModel.chatId == groupInfo.id { + chatModel.groupMembers = groupMembers.map { GMember.init($0) } + membersLoaded = true + updateView() + } + } + } + private func initChatView() { let cInfo = chat.chatInfo if case let .direct(contact) = cInfo { @@ -416,13 +431,7 @@ struct ChatView: View { private func addMembersButton() -> some View { Button { if case let .group(gInfo) = chat.chatInfo { - Task { - let groupMembers = await apiListMembers(gInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - showAddMembersSheet = true - } - } + Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } } } } label: { Image(systemName: "person.crop.circle.badge.plus") @@ -477,73 +486,30 @@ struct ChatView: View { } @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { - if case let .groupRcv(member) = ci.chatDir, - case let .group(groupInfo) = chat.chatInfo { - let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci) - if ci.memberConnected != nil && nextItem?.memberConnected != nil { - // memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView - ZStack {} // scroll doesn't work if it's EmptyView() - } else { - if prevItem == nil || showMemberImage(member, prevItem) { - VStack(alignment: .leading, spacing: 4) { - if ci.content.showMemberName { - Text(member.displayName) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, memberImageSize + 14) - .padding(.top, 7) - } - HStack(alignment: .top, spacing: 8) { - ProfileImage(imageStr: member.memberProfile.image) - .frame(width: memberImageSize, height: memberImageSize) - .onTapGesture { selectedMember = member } - .appSheet(item: $selectedMember) { member in - GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true) - } - chatItemWithMenu(ci, maxWidth) - } - } - .padding(.top, 5) - .padding(.trailing) - .padding(.leading, 12) - } else { - chatItemWithMenu(ci, maxWidth) - .padding(.top, 5) - .padding(.trailing) - .padding(.leading, memberImageSize + 8 + 12) - } - } - } else { - chatItemWithMenu(ci, maxWidth) - .padding(.horizontal) - .padding(.top, 5) - } - } - - private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { ChatItemWithMenu( - ci: ci, + chat: chat, + chatItem: ci, maxWidth: maxWidth, - scrollProxy: scrollProxy, - deleteMessage: deleteMessage, - deletingItem: $deletingItem, composeState: $composeState, - showDeleteMessage: $showDeleteMessage + selectedMember: $selectedMember, + chatView: self ) - .environmentObject(chat) } private struct ChatItemWithMenu: View { - @EnvironmentObject var chat: Chat + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme - var ci: ChatItem + @ObservedObject var chat: Chat + var chatItem: ChatItem var maxWidth: CGFloat - var scrollProxy: ScrollViewProxy? - var deleteMessage: (CIDeleteMode) -> Void - @Binding var deletingItem: ChatItem? @Binding var composeState: ComposeState - @Binding var showDeleteMessage: Bool + @Binding var selectedMember: GMember? + var chatView: ChatView + @State private var deletingItem: ChatItem? = nil + @State private var showDeleteMessage = false + @State private var deletingItems: [Int64] = [] + @State private var showDeleteMessages = false @State private var revealed = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @@ -555,18 +521,114 @@ struct ChatView: View { @State private var playbackTime: TimeInterval? var body: some View { + let (currIndex, nextItem) = m.getNextChatItem(chatItem) + let ciCategory = chatItem.mergeCategory + if (ciCategory != nil && ciCategory == nextItem?.mergeCategory) { + // memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView + ZStack {} // scroll doesn't work if it's EmptyView() + } else { + let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) + let range = itemsRange(currIndex, prevHidden) + if revealed, let range = range { + let items = Array(zip(Array(range), m.reversedChatItems[range])) + ForEach(items, id: \.1.viewId) { (i, ci) in + let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1] + chatItemView(ci, nil, prev) + } + } else { + chatItemView(chatItem, range, prevItem) + } + } + } + + @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?) -> some View { + if case let .groupRcv(member) = ci.chatDir, + case let .group(groupInfo) = chat.chatInfo { + let (prevMember, memCount): (GroupMember?, Int) = + if let range = range { + m.getPrevHiddenMember(member, range) + } else { + (nil, 1) + } + if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil { + VStack(alignment: .leading, spacing: 4) { + if ci.content.showMemberName { + Text(memberNames(member, prevMember, memCount)) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, memberImageSize + 14) + .padding(.top, 7) + } + HStack(alignment: .top, spacing: 8) { + ProfileImage(imageStr: member.memberProfile.image) + .frame(width: memberImageSize, height: memberImageSize) + .onTapGesture { + if chatView.membersLoaded { + selectedMember = m.getGroupMember(member.groupMemberId) + } else { + Task { + await chatView.loadGroupMembers(groupInfo) { + selectedMember = m.getGroupMember(member.groupMemberId) + } + } + } + } + .appSheet(item: $selectedMember) { member in + GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true) + } + chatItemWithMenu(ci, range, maxWidth) + } + } + .padding(.top, 5) + .padding(.trailing) + .padding(.leading, 12) + } else { + chatItemWithMenu(ci, range, maxWidth) + .padding(.top, 5) + .padding(.trailing) + .padding(.leading, memberImageSize + 8 + 12) + } + } else { + chatItemWithMenu(ci, range, maxWidth) + .padding(.horizontal) + .padding(.top, 5) + } + } + + private func memberNames(_ member: GroupMember, _ prevMember: GroupMember?, _ memCount: Int) -> LocalizedStringKey { + let name = member.displayName + return if let prevName = prevMember?.displayName { + memCount > 2 + ? "\(name), \(prevName) and \(memCount - 2) members" + : "\(name) and \(prevName)" + } else { + "\(name)" + } + } + + @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let uiMenu: Binding = Binding( - get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) }, + get: { UIMenu(title: "", children: menu(ci, range, live: composeState.liveMessage != nil)) }, set: { _ in } ) VStack(alignment: alignment.horizontal, spacing: 3) { - ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) - .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) - .accessibilityLabel("") + ChatItemView( + chat: chat, + chatItem: ci, + maxWidth: maxWidth, + scrollProxy: chatView.scrollProxy, + revealed: $revealed, + allowMenu: $allowMenu, + audioPlayer: $audioPlayer, + playbackState: $playbackState, + playbackTime: $playbackTime + ) + .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) + .accessibilityLabel("") if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { - chatItemReactions() + chatItemReactions(ci) .padding(.bottom, 4) } } @@ -580,6 +642,11 @@ struct ChatView: View { } } } + .confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) { + Button("Delete for me", role: .destructive) { + deleteMessages() + } + } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .onDisappear { @@ -597,7 +664,15 @@ struct ChatView: View { } } - private func chatItemReactions() -> some View { + private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { + switch (prevItem?.chatDir) { + case .groupSnd: return true + case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId + default: return false + } + } + + private func chatItemReactions(_ ci: ChatItem) -> some View { HStack(spacing: 4) { ForEach(ci.reactions, id: \.reaction) { r in let v = HStack(spacing: 4) { @@ -617,7 +692,7 @@ struct ChatView: View { if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) { v.onTapGesture { - setReaction(add: !r.userReacted, reaction: r.reaction) + setReaction(ci, add: !r.userReacted, reaction: r.reaction) } } else { v @@ -626,10 +701,10 @@ struct ChatView: View { } } - private func menu(live: Bool) -> [UIMenuElement] { + private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> [UIMenuElement] { var menu: [UIMenuElement] = [] if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { - let rs = allReactions() + let rs = allReactions(ci) if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, rs.count > 0 { var rm: UIMenu @@ -646,10 +721,10 @@ struct ChatView: View { menu.append(rm) } if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live { - menu.append(replyUIAction()) + menu.append(replyUIAction(ci)) } - menu.append(shareUIAction()) - menu.append(copyUIAction()) + menu.append(shareUIAction(ci)) + menu.append(copyUIAction(ci)) if let fileSource = getLoadedFileSource(ci.file) { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if image.imageData != nil { @@ -662,9 +737,9 @@ struct ChatView: View { } } if ci.meta.editable && !mc.isVoice && !live { - menu.append(editAction()) + menu.append(editAction(ci)) } - menu.append(viewInfoUIAction()) + menu.append(viewInfoUIAction(ci)) if revealed { menu.append(hideUIAction()) } @@ -674,25 +749,31 @@ struct ChatView: View { menu.append(cancelFileUIAction(file.fileId, cancelAction)) } if !live || !ci.meta.isLive { - menu.append(deleteUIAction()) + menu.append(deleteUIAction(ci)) } if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { - menu.append(moderateUIAction(groupInfo)) + menu.append(moderateUIAction(ci, groupInfo)) } } else if ci.meta.itemDeleted != nil { - if !ci.isDeletedContent { + if revealed { + menu.append(hideUIAction()) + } else if !ci.isDeletedContent { menu.append(revealUIAction()) + } else if range != nil { + menu.append(expandUIAction()) } - menu.append(viewInfoUIAction()) - menu.append(deleteUIAction()) + menu.append(viewInfoUIAction(ci)) + menu.append(deleteUIAction(ci)) } else if ci.isDeletedContent { - menu.append(viewInfoUIAction()) - menu.append(deleteUIAction()) + menu.append(viewInfoUIAction(ci)) + menu.append(deleteUIAction(ci)) + } else if ci.mergeCategory != nil { + menu.append(revealed ? shrinkUIAction() : expandUIAction()) } return menu } - private func replyUIAction() -> UIAction { + private func replyUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Reply", comment: "chat item action"), image: UIImage(systemName: "arrowshape.turn.up.left") @@ -727,11 +808,11 @@ struct ChatView: View { ) } - private func allReactions() -> [UIAction] { + private func allReactions(_ ci: ChatItem) -> [UIAction] { MsgReaction.values.compactMap { r in ci.reactions.contains(where: { $0.userReacted && $0.reaction == r }) ? nil - : UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) } + : UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) } } } @@ -739,7 +820,7 @@ struct ChatView: View { rs.count > 4 ? 3 : 4 } - private func setReaction(add: Bool, reaction: MsgReaction) { + private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) { Task { do { let cInfo = chat.chatInfo @@ -751,7 +832,7 @@ struct ChatView: View { reaction: reaction ) await MainActor.run { - ChatModel.shared.updateChatItem(chat.chatInfo, chatItem) + m.updateChatItem(chat.chatInfo, chatItem) } } catch let error { logger.error("apiChatItemReaction error: \(responseError(error))") @@ -759,7 +840,7 @@ struct ChatView: View { } } - private func shareUIAction() -> UIAction { + private func shareUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Share", comment: "chat item action"), image: UIImage(systemName: "square.and.arrow.up") @@ -772,7 +853,7 @@ struct ChatView: View { } } - private func copyUIAction() -> UIAction { + private func copyUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Copy", comment: "chat item action"), image: UIImage(systemName: "doc.on.doc") @@ -805,7 +886,7 @@ struct ChatView: View { } } - private func editAction() -> UIAction { + private func editAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Edit", comment: "chat item action"), image: UIImage(systemName: "square.and.pencil") @@ -816,7 +897,7 @@ struct ChatView: View { } } - private func viewInfoUIAction() -> UIAction { + private func viewInfoUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Info", comment: "chat item action"), image: UIImage(systemName: "info.circle") @@ -829,10 +910,7 @@ struct ChatView: View { chatItemInfo = ciInfo } if case let .group(gInfo) = chat.chatInfo { - let groupMembers = await apiListMembers(gInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - } + await chatView.loadGroupMembers(gInfo) } } catch let error { logger.error("apiGetChatItemInfo error: \(responseError(error))") @@ -853,7 +931,7 @@ struct ChatView: View { message: Text(cancelAction.alert.message), primaryButton: .destructive(Text(cancelAction.alert.confirm)) { Task { - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { await cancelFile(user: user, fileId: fileId) } } @@ -874,18 +952,45 @@ struct ChatView: View { } } - private func deleteUIAction() -> UIAction { + private func deleteUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Delete", comment: "chat item action"), image: UIImage(systemName: "trash"), attributes: [.destructive] ) { _ in - showDeleteMessage = true - deletingItem = ci + if !revealed && ci.meta.itemDeleted != nil, + let currIndex = m.getChatItemIndex(ci), + let ciCategory = ci.mergeCategory { + let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) + if let range = itemsRange(currIndex, prevHidden) { + var itemIds: [Int64] = [] + for i in range { + itemIds.append(m.reversedChatItems[i].id) + } + showDeleteMessages = true + deletingItems = itemIds + } else { + showDeleteMessage = true + deletingItem = ci + } + } else { + showDeleteMessage = true + deletingItem = ci + } } } - private func moderateUIAction(_ groupInfo: GroupInfo) -> UIAction { + private func itemsRange(_ currIndex: Int?, _ prevHidden: Int?) -> ClosedRange? { + if let currIndex = currIndex, + let prevHidden = prevHidden, + prevHidden > currIndex { + currIndex...prevHidden + } else { + nil + } + } + + private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction { UIAction( title: NSLocalizedString("Moderate", comment: "chat item action"), image: UIImage(systemName: "flag"), @@ -917,20 +1022,105 @@ struct ChatView: View { } } } - + + private func expandUIAction() -> UIAction { + UIAction( + title: NSLocalizedString("Expand", comment: "chat item action"), + image: UIImage(systemName: "arrow.up.and.line.horizontal.and.arrow.down") + ) { _ in + withAnimation { + revealed = true + } + } + } + + private func shrinkUIAction() -> UIAction { + UIAction( + title: NSLocalizedString("Hide", comment: "chat item action"), + image: UIImage(systemName: "arrow.down.and.line.horizontal.and.arrow.up") + ) { _ in + withAnimation { + revealed = false + } + } + } + private var broadcastDeleteButtonText: LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } - } - private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { - switch (prevItem?.chatDir) { - case .groupSnd: return true - case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId - default: return false + var deleteMessagesTitle: LocalizedStringKey { + let n = deletingItems.count + return n == 1 ? "Delete message?" : "Delete \(n) messages?" + } + + private func deleteMessages() { + let itemIds = deletingItems + if itemIds.count > 0 { + let chatInfo = chat.chatInfo + Task { + var deletedItems: [ChatItem] = [] + for itemId in itemIds { + do { + let (di, _) = try await apiDeleteChatItem( + type: chatInfo.chatType, + id: chatInfo.apiId, + itemId: itemId, + mode: .cidmInternal + ) + deletedItems.append(di) + } catch { + logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + } + } + await MainActor.run { + for di in deletedItems { + m.removeChatItem(chatInfo, di) + } + } + } + } + } + + private func deleteMessage(_ mode: CIDeleteMode) { + logger.debug("ChatView deleteMessage") + Task { + logger.debug("ChatView deleteMessage: in Task") + do { + if let di = deletingItem { + var deletedItem: ChatItem + var toItem: ChatItem? + if case .cidmBroadcast = mode, + let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) { + (deletedItem, toItem) = try await apiDeleteMemberChatItem( + groupId: groupInfo.apiId, + groupMemberId: groupMember.groupMemberId, + itemId: di.id + ) + } else { + (deletedItem, toItem) = try await apiDeleteChatItem( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemId: di.id, + mode: mode + ) + } + DispatchQueue.main.async { + deletingItem = nil + if let toItem = toItem { + _ = m.upsertChatItem(chat.chatInfo, toItem) + } else { + m.removeChatItem(chat.chatInfo, deletedItem) + } + } + } + } catch { + logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + } + } } } - + private func scrollToBottom(_ proxy: ScrollViewProxy) { if let ci = chatModel.reversedChatItems.first { withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } @@ -942,44 +1132,6 @@ struct ChatView: View { withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } } } - - private func deleteMessage(_ mode: CIDeleteMode) { - logger.debug("ChatView deleteMessage") - Task { - logger.debug("ChatView deleteMessage: in Task") - do { - if let di = deletingItem { - var deletedItem: ChatItem - var toItem: ChatItem? - if case .cidmBroadcast = mode, - let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) { - (deletedItem, toItem) = try await apiDeleteMemberChatItem( - groupId: groupInfo.apiId, - groupMemberId: groupMember.groupMemberId, - itemId: di.id - ) - } else { - (deletedItem, toItem) = try await apiDeleteChatItem( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - itemId: di.id, - mode: mode - ) - } - DispatchQueue.main.async { - deletingItem = nil - if let toItem = toItem { - _ = chatModel.upsertChatItem(chat.chatInfo, toItem) - } else { - chatModel.removeChatItem(chat.chatInfo, deletedItem) - } - } - } - } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") - } - } - } } @ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3328da8dbe..0572821770 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -592,12 +592,14 @@ struct ComposeView: View { EmptyView() case let .quotedItem(chatItem: quotedItem): ContextItemView( + chat: chat, contextItem: quotedItem, contextIcon: "arrowshape.turn.up.left", cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) case let .editingItem(chatItem: editingItem): ContextItemView( + chat: chat, contextItem: editingItem, contextIcon: "pencil", cancelContextItem: { clearState() } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 153d89fc27..868ae3274a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct ContextItemView: View { @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat let contextItem: ChatItem let contextIcon: String let cancelContextItem: () -> Void @@ -48,6 +49,7 @@ struct ContextItemView: View { private func msgContentView(lines: Int) -> some View { MsgContentView( + chat: chat, text: contextItem.text, formattedText: contextItem.formattedText ) @@ -59,6 +61,6 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") - return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) + return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) } } diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 123d93714d..d206b9b418 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -144,7 +144,7 @@ struct AddGroupMembersViewCommon: View { do { for contactId in selectedContacts { let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole) - await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) } + await MainActor.run { _ = chatModel.upsertGroupMember(groupInfo, member) } } addedMembersCb(selectedContacts) } catch { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index dd2392b6dc..94a018749e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -15,7 +15,7 @@ struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat - @State var groupInfo: GroupInfo + @Binding var groupInfo: GroupInfo @ObservedObject private var alertManager = AlertManager.shared @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @@ -35,14 +35,30 @@ struct GroupChatInfoView: View { case leaveGroupAlert case cantInviteIncognitoAlert case largeGroupReceiptsDisabled + case blockMemberAlert(mem: GroupMember) + case unblockMemberAlert(mem: GroupMember) + case removeMemberAlert(mem: GroupMember) + case error(title: LocalizedStringKey, error: LocalizedStringKey) - var id: GroupChatInfoViewAlert { get { self } } + var id: String { + switch self { + case .deleteGroupAlert: return "deleteGroupAlert" + case .clearChatAlert: return "clearChatAlert" + case .leaveGroupAlert: return "leaveGroupAlert" + case .cantInviteIncognitoAlert: return "cantInviteIncognitoAlert" + case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled" + case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" + case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" + case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" + case let .error(title, _): return "error \(title)" + } + } } var body: some View { NavigationView { let members = chatModel.groupMembers - .filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved } + .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() } List { @@ -57,7 +73,7 @@ struct GroupChatInfoView: View { addOrEditWelcomeMessage() } groupPreferencesButton($groupInfo) - if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { sendReceiptsOption() } else { sendReceiptsOptionDisabled() @@ -84,17 +100,17 @@ struct GroupChatInfoView: View { .padding(.leading, 8) } let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" ? members : members.filter { $0.chatViewName.localizedLowercase.contains(s) } - memberView(groupInfo.membership, user: true) + let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } + MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) ForEach(filteredMembers) { member in ZStack { NavigationLink { - memberInfoView(member.groupMemberId) + memberInfoView(member) } label: { EmptyView() } .opacity(0) - memberView(member) + MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) } } } @@ -126,6 +142,10 @@ struct GroupChatInfoView: View { case .leaveGroupAlert: return leaveGroupAlert() case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert() case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert() + case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) + case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) + case let .removeMemberAlert(mem): return removeMemberAlert(mem) + case let .error(title, error): return Alert(title: Text(title), message: Text(error)) } } .onAppear { @@ -174,7 +194,7 @@ struct GroupChatInfoView: View { Task { let groupMembers = await apiListMembers(groupInfo.groupId) await MainActor.run { - ChatModel.shared.groupMembers = groupMembers + chatModel.groupMembers = groupMembers.map { GMember.init($0) } } } } @@ -183,44 +203,79 @@ struct GroupChatInfoView: View { } } - private func memberView(_ member: GroupMember, user: Bool = false) -> some View { - HStack{ - ProfileImage(imageStr: member.image) - .frame(width: 38, height: 38) - .padding(.trailing, 2) - // TODO server connection status - VStack(alignment: .leading) { - let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary) - (member.verified ? memberVerifiedShield + t : t) - .lineLimit(1) - let s = Text(member.memberStatus.shortText) - (user ? Text ("you: ") + s : s) - .lineLimit(1) - .font(.caption) - .foregroundColor(.secondary) + private struct MemberRowView: View { + var groupInfo: GroupInfo + @ObservedObject var groupMember: GMember + var user: Bool = false + @Binding var alert: GroupChatInfoViewAlert? + + var body: some View { + let member = groupMember.wrapped + let v = HStack{ + ProfileImage(imageStr: member.image) + .frame(width: 38, height: 38) + .padding(.trailing, 2) + // TODO server connection status + VStack(alignment: .leading) { + let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary) + (member.verified ? memberVerifiedShield + t : t) + .lineLimit(1) + let s = Text(member.memberStatus.shortText) + (user ? Text ("you: ") + s : s) + .lineLimit(1) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + let role = member.memberRole + if role == .owner || role == .admin { + Text(member.memberRole.text) + .foregroundColor(.secondary) + } } - Spacer() - let role = member.memberRole - if role == .owner || role == .admin { - Text(member.memberRole.text) - .foregroundColor(.secondary) + + if user { + v + } else if member.canBeRemoved(groupInfo: groupInfo) { + removeSwipe(member, blockSwipe(member, v)) + } else { + blockSwipe(member, v) + } + } + + private func blockSwipe(_ member: GroupMember, _ v: V) -> some View { + v.swipeActions(edge: .leading) { + if member.memberSettings.showMessages { + Button { + alert = .blockMemberAlert(mem: member) + } label: { + Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary) + } + } else { + Button { + alert = .unblockMemberAlert(mem: member) + } label: { + Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor) + } + } + } + } + + private func removeSwipe(_ member: GroupMember, _ v: V) -> some View { + v.swipeActions(edge: .trailing) { + Button(role: .destructive) { + alert = .removeMemberAlert(mem: member) + } label: { + Label("Remove member", systemImage: "trash") + .foregroundColor(Color.red) + } } } } - private var memberVerifiedShield: Text { - (Text(Image(systemName: "checkmark.shield")) + Text(" ")) - .font(.caption) - .baselineOffset(2) - .kerning(-2) - .foregroundColor(.secondary) - } - - @ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View { - if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) { - GroupMemberInfoView(groupInfo: groupInfo, member: member) - .navigationBarHidden(false) - } + private func memberInfoView(_ groupMember: GMember) -> some View { + GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember) + .navigationBarHidden(false) } private func groupLinkButton() -> some View { @@ -381,6 +436,28 @@ struct GroupChatInfoView: View { alert = .largeGroupReceiptsDisabled } } + + private func removeMemberAlert(_ mem: GroupMember) -> Alert { + Alert( + title: Text("Remove member?"), + message: Text("Member will be removed from group - this cannot be undone!"), + primaryButton: .destructive(Text("Remove")) { + Task { + do { + let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + await MainActor.run { + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } + } catch let error { + logger.error("apiRemoveMember error: \(responseError(error))") + let a = getErrorAlert(error, "Error removing member") + alert = .error(title: a.title, error: a.message) + } + } + }, + secondaryButton: .cancel() + ) + } } func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bool = false) -> some View { @@ -402,6 +479,14 @@ func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bo } } +private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + Text(" ")) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(.secondary) +} + func cantInviteIncognitoAlert() -> Alert { Alert( title: Text("Can't invite contacts!"), @@ -418,6 +503,9 @@ func largeGroupReceiptsDisabledAlert() -> Alert { struct GroupChatInfoView_Previews: PreviewProvider { static var previews: some View { - GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData) + GroupChatInfoView( + chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), + groupInfo: Binding.constant(GroupInfo.sampleData) + ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 4b64458145..4a187cecb9 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -12,8 +12,8 @@ import SimpleXChat struct GroupMemberInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction - var groupInfo: GroupInfo - @State var member: GroupMember + @State var groupInfo: GroupInfo + @ObservedObject var groupMember: GMember var navigation: Bool = false @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @@ -25,6 +25,8 @@ struct GroupMemberInfoView: View { @State private var progressIndicator = false enum GroupMemberInfoViewAlert: Identifiable { + case blockMemberAlert(mem: GroupMember) + case unblockMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember) case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case switchAddressAlert @@ -35,8 +37,10 @@ struct GroupMemberInfoView: View { var id: String { switch self { - case .removeMemberAlert: return "removeMemberAlert" - case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)" + case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" + case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" + case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" + case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)" case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" @@ -66,6 +70,7 @@ struct GroupMemberInfoView: View { private func groupMemberInfoView() -> some View { ZStack { VStack { + let member = groupMember.wrapped List { groupMemberInfoHeader(member) .listRowBackground(Color.clear) @@ -159,9 +164,14 @@ struct GroupMemberInfoView: View { } } - if member.canBeRemoved(groupInfo: groupInfo) { - Section { - removeMemberButton(member) + Section { + if member.memberSettings.showMessages { + blockMemberButton(member) + } else { + unblockMemberButton(member) + } + if member.canBeRemoved(groupInfo: groupInfo) { + removeMemberButton(member) } } @@ -182,7 +192,7 @@ struct GroupMemberInfoView: View { do { let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) - member = mem + _ = chatModel.upsertGroupMember(groupInfo, mem) connectionStats = stats connectionCode = code } catch let error { @@ -190,15 +200,20 @@ struct GroupMemberInfoView: View { } justOpened = false } - .onChange(of: newRole) { _ in + .onChange(of: newRole) { newRole in if newRole != member.memberRole { alert = .changeMemberRoleAlert(mem: member, role: newRole) } } + .onChange(of: member.memberRole) { role in + newRole = role + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .alert(item: $alert) { alertItem in switch(alertItem) { + case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) + case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem) case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) @@ -263,7 +278,7 @@ struct GroupMemberInfoView: View { progressIndicator = true Task { do { - let memberContact = try await apiCreateMemberContact(groupInfo.apiId, member.groupMemberId) + let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId) await MainActor.run { progressIndicator = false chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) @@ -321,20 +336,20 @@ struct GroupMemberInfoView: View { } private func verifyCodeButton(_ code: String) -> some View { - NavigationLink { + let member = groupMember.wrapped + return NavigationLink { VerifyCodeView( displayName: member.displayName, connectionCode: code, connectionVerified: member.verified, verify: { code in + var member = groupMember.wrapped if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) { let (verified, existingCode) = r let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil connectionCode = existingCode member.activeConn?.connectionCode = connCode - if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) { - chatModel.groupMembers[i].activeConn?.connectionCode = connCode - } + _ = chatModel.upsertGroupMember(groupInfo, member) return r } return nil @@ -368,12 +383,29 @@ struct GroupMemberInfoView: View { } } + private func blockMemberButton(_ mem: GroupMember) -> some View { + Button(role: .destructive) { + alert = .blockMemberAlert(mem: mem) + } label: { + Label("Block member", systemImage: "hand.raised") + .foregroundColor(.red) + } + } + + private func unblockMemberButton(_ mem: GroupMember) -> some View { + Button { + alert = .unblockMemberAlert(mem: mem) + } label: { + Label("Unblock member", systemImage: "hand.raised.slash") + } + } + private func removeMemberButton(_ mem: GroupMember) -> some View { Button(role: .destructive) { alert = .removeMemberAlert(mem: mem) } label: { Label("Remove member", systemImage: "trash") - .foregroundColor(Color.red) + .foregroundColor(.red) } } @@ -409,7 +441,6 @@ struct GroupMemberInfoView: View { do { let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole) await MainActor.run { - member = updatedMember _ = chatModel.upsertGroupMember(groupInfo, updatedMember) } @@ -430,10 +461,10 @@ struct GroupMemberInfoView: View { private func switchMemberAddress() { Task { do { - let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + let stats = try apiSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId) connectionStats = stats await MainActor.run { - chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) + chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats) dismiss() } } catch let error { @@ -449,10 +480,10 @@ struct GroupMemberInfoView: View { private func abortSwitchMemberAddress() { Task { do { - let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId) connectionStats = stats await MainActor.run { - chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) + chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats) } } catch let error { logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))") @@ -467,7 +498,7 @@ struct GroupMemberInfoView: View { private func syncMemberConnection(force: Bool) { Task { do { - let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force) + let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, groupMember.groupMemberId, force) connectionStats = stats await MainActor.run { chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats) @@ -484,11 +515,54 @@ struct GroupMemberInfoView: View { } } +func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { + Alert( + title: Text("Block member?"), + message: Text("All new messages from \(mem.chatViewName) will be hidden!"), + primaryButton: .destructive(Text("Block")) { + toggleShowMemberMessages(gInfo, mem, false) + }, + secondaryButton: .cancel() + ) +} + +func unblockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { + Alert( + title: Text("Unblock member?"), + message: Text("Messages from \(mem.chatViewName) will be shown!"), + primaryButton: .default(Text("Unblock")) { + toggleShowMemberMessages(gInfo, mem, true) + }, + secondaryButton: .cancel() + ) +} + +func toggleShowMemberMessages(_ gInfo: GroupInfo, _ member: GroupMember, _ showMessages: Bool) { + var memberSettings = member.memberSettings + memberSettings.showMessages = showMessages + updateMemberSettings(gInfo, member, memberSettings) +} + +func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSettings: GroupMemberSettings) { + Task { + do { + try await apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings) + await MainActor.run { + var mem = member + mem.memberSettings = memberSettings + _ = ChatModel.shared.upsertGroupMember(gInfo, mem) + } + } catch let error { + logger.error("apiSetMemberSettings error \(responseError(error))") + } + } +} + struct GroupMemberInfoView_Previews: PreviewProvider { static var previews: some View { GroupMemberInfoView( groupInfo: GroupInfo.sampleData, - member: GroupMember.sampleData + groupMember: GMember.sampleData ) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index eb0a5cba68..baab2bcf95 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -29,7 +29,7 @@ struct ChatListView: View { ZStack(alignment: .topLeading) { NavStackCompat( isActive: Binding( - get: { ChatModel.shared.chatId != nil }, + get: { chatModel.chatId != nil }, set: { _ in } ), destination: chatView diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index 7c973c73c6..6d2fba99c6 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -119,7 +119,7 @@ struct ContactConnectionInfo: View { if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) { await MainActor.run { contactConnection = conn - ChatModel.shared.updateContactConnection(conn) + m.updateContactConnection(conn) dismiss() } } diff --git a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift index 69393cabb8..5b982f5f0a 100644 --- a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift +++ b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift @@ -52,7 +52,6 @@ struct VideoPlayerView: UIViewRepresentable { var timeObserver: Any? = nil deinit { - print("deinit coordinator of VideoPlayer") if let timeObserver = timeObserver { NotificationCenter.default.removeObserver(timeObserver) } diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 344a8d1f96..de8e35d2a6 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -48,7 +48,7 @@ struct AddContactView: View { let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) { await MainActor.run { contactConnection = conn - ChatModel.shared.updateContactConnection(conn) + chatModel.updateContactConnection(conn) } } } catch { diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 22bf1c4096..2d7f31c58e 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -189,7 +189,7 @@ struct AddGroupView: View { Task { let groupMembers = await apiListMembers(gInfo.groupId) await MainActor.run { - ChatModel.shared.groupMembers = groupMembers + m.groupMembers = groupMembers.map { GMember.init($0) } } } let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 51bfb96940..90b83fa4f3 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -119,7 +119,7 @@ struct PrivacySettings: View { Text("Send delivery receipts to") } footer: { VStack(alignment: .leading) { - Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.") + Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") Text("They can be overridden in contact and group settings.") } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4afe2583ca..8c15d94537 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -73,6 +73,7 @@ public enum ChatCommand { case apiGetNetworkConfig case reconnectAllServers case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) + case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) case apiContactInfo(contactId: Int64) case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case apiSwitchContact(contactId: Int64) @@ -198,6 +199,7 @@ public enum ChatCommand { case .apiGetNetworkConfig: return "/network" case .reconnectAllServers: return "/reconnect" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" + case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" @@ -325,6 +327,7 @@ public enum ChatCommand { case .apiGetNetworkConfig: return "apiGetNetworkConfig" case .reconnectAllServers: return "reconnectAllServers" case .apiSetChatSettings: return "apiSetChatSettings" + case .apiSetMemberSettings: return "apiSetMemberSettings" case .apiContactInfo: return "apiContactInfo" case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .apiSwitchContact: return "apiSwitchContact" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c31786c700..25511e1bae 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -422,8 +422,8 @@ public enum CustomTimeUnit { public func timeText(_ seconds: Int?) -> String { - guard let seconds = seconds else { return "off" } - if seconds == 0 { return "0 sec" } + guard let seconds = seconds else { return NSLocalizedString("off", comment: "time to disappear") } + if seconds == 0 { return NSLocalizedString("0 sec", comment: "time to disappear") } return CustomTimeUnit.toText(seconds: seconds) } @@ -1867,8 +1867,8 @@ public struct GroupMember: Identifiable, Decodable { ) } -public struct GroupMemberSettings: Decodable { - var showMessages: Bool +public struct GroupMemberSettings: Codable { + public var showMessages: Bool } public struct GroupMemberRef: Decodable { @@ -2090,7 +2090,7 @@ public struct ChatItem: Identifiable, Decodable { public var memberConnected: GroupMember? { switch chatDir { - case .groupRcv(let groupMember): + case let .groupRcv(groupMember): switch content { case .rcvGroupEvent(rcvGroupEvent: .memberConnected): return groupMember default: return nil @@ -2099,6 +2099,35 @@ public struct ChatItem: Identifiable, Decodable { } } + public var mergeCategory: CIMergeCategory? { + switch content { + case .rcvChatFeature: .chatFeature + case .sndChatFeature: .chatFeature + case .rcvGroupFeature: .chatFeature + case .sndGroupFeature: .chatFeature + case let.rcvGroupEvent(event): + switch event { + case .userRole: nil + case .userDeleted: nil + case .groupDeleted: nil + case .memberCreatedContact: nil + default: .rcvGroupEvent + } + case let .sndGroupEvent(event): + switch event { + case .userRole: nil + case .userLeft: nil + default: .sndGroupEvent + } + default: + if meta.itemDeleted == nil { + nil + } else { + chatDir.sent ? .sndItemDeleted : .rcvItemDeleted + } + } + } + private var showNtfDir: Bool { return !chatDir.sent } @@ -2176,7 +2205,7 @@ public struct ChatItem: Identifiable, Decodable { public var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { - return groupMember.displayName + return groupMember.chatViewName } else { return nil } @@ -2330,6 +2359,15 @@ public struct ChatItem: Identifiable, Decodable { } } +public enum CIMergeCategory { + case memberConnected + case rcvGroupEvent + case sndGroupEvent + case sndItemDeleted + case rcvItemDeleted + case chatFeature +} + public enum CIDirection: Decodable { case directSnd case directRcv @@ -2508,11 +2546,13 @@ public enum SndCIStatusProgress: String, Decodable { public enum CIDeleted: Decodable { case deleted(deletedTs: Date?) + case blocked(deletedTs: Date?) case moderated(deletedTs: Date?, byGroupMember: GroupMember) var id: String { switch self { case .deleted: return "deleted" + case .blocked: return "blocked" case .moderated: return "moderated" } } @@ -2530,8 +2570,8 @@ protocol ItemContent { public enum CIContent: Decodable, ItemContent { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) - case sndDeleted(deleteMode: CIDeleteMode) - case rcvDeleted(deleteMode: CIDeleteMode) + case sndDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used + case rcvDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used case sndCall(status: CICallStatus, duration: Int) case rcvCall(status: CICallStatus, duration: Int) case rcvIntegrityError(msgError: MsgErrorType) From 02c0cd5619e1683e6cbfc40c1a7ac99f5c6e88de Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 1 Nov 2023 12:48:58 +0200 Subject: [PATCH 131/294] Cut at attaching http server/client (#3299) * Cut at attaching http server/client * switch to xrcp branch --- cabal.project | 2 +- package.yaml | 1 - simplex-chat.cabal | 11 +- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Remote.hs | 8 +- src/Simplex/Chat/Remote/Discovery.hs | 236 --------------------------- src/Simplex/Chat/Remote/RevHTTP.hs | 54 ++++++ src/Simplex/Chat/Remote/Types.hs | 219 +++---------------------- src/Simplex/Chat/Store/Remote.hs | 1 + tests/RemoteTests.hs | 72 ++++---- 10 files changed, 121 insertions(+), 484 deletions(-) delete mode 100644 src/Simplex/Chat/Remote/Discovery.hs create mode 100644 src/Simplex/Chat/Remote/RevHTTP.hs diff --git a/cabal.project b/cabal.project index 9522fe2337..b693f6c88f 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 0410948b56ea630dfa86441bbcf8ec97aeb1df01 + tag: db1b2f77cd1c172fab26b68c507cdd2c1b7b0e63 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 095d2c1342..2bfb36d18d 100644 --- a/package.yaml +++ b/package.yaml @@ -36,7 +36,6 @@ dependencies: - mtl == 2.3.* - network >= 3.1.2.7 && < 3.2 - network-transport == 0.5.6 - - network-udp >= 0.0 && < 0.1 - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* - random >= 1.1 && < 1.3 diff --git a/simplex-chat.cabal b/simplex-chat.cabal index db03249312..2dc77c3c22 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.35.2. +-- This file has been generated from package.yaml by hpack version 0.36.0. -- -- see: https://github.com/sol/hpack @@ -130,9 +130,9 @@ library Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Remote - Simplex.Chat.Remote.Discovery Simplex.Chat.Remote.Multicast Simplex.Chat.Remote.Protocol + Simplex.Chat.Remote.RevHTTP Simplex.Chat.Remote.Transport Simplex.Chat.Remote.Types Simplex.Chat.Store @@ -184,7 +184,6 @@ library , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -237,7 +236,6 @@ executable simplex-bot , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -291,7 +289,6 @@ executable simplex-bot-advanced , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -347,7 +344,6 @@ executable simplex-broadcast-bot , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -402,7 +398,6 @@ executable simplex-chat , mtl ==2.3.* , network ==3.1.* , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -461,7 +456,6 @@ executable simplex-directory-service , mtl ==2.3.* , network >=3.1.2.7 && <3.2 , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -546,7 +540,6 @@ test-suite simplex-chat-test , mtl ==2.3.* , network ==3.1.* , network-transport ==0.5.6 - , network-udp ==0.0.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a54c6320e8..25fe9294c6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -73,6 +73,7 @@ import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version +import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) import UnliftIO.STM diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d6ccd25961..cc3eb9f199 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -40,8 +40,8 @@ import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Messages (chatNameStr) -import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Protocol +import Simplex.Chat.Remote.RevHTTP (announceRevHTTP2, connectRevHTTP2) import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Files @@ -61,6 +61,8 @@ import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=), (<$$>)) +import qualified Simplex.RemoteControl.Discovery as Discovery +import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) @@ -113,7 +115,7 @@ startRemoteHost rhId = do localAddr <- asks multicastSubscribers >>= Discovery.getLocalAddress >>= maybe (throwError . ChatError $ CEInternalError "unable to get local address") pure (dhKey, sigKey, ann, oob) <- Discovery.startSession (if rcName == "" then Nothing else Just rcName) (localAddr, read Discovery.DISCOVERY_PORT) fingerprint toView CRRemoteHostStarted {remoteHost = remoteHostInfo rh True, sessionOOB = decodeUtf8 $ strEncode oob} - httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ Discovery.announceRevHTTP2 tasks (sigKey, ann) credentials cleanupIO + httpClient <- liftEitherError (ChatErrorRemoteCtrl . RCEHTTP2Error . show) $ announceRevHTTP2 tasks (sigKey, ann) credentials cleanupIO logInfo $ "Remote host session connected for " <> tshow rhId -- test connection and establish a protocol layer remoteHostClient <- liftRH rhId $ createRemoteHostClient httpClient dhKey rcName @@ -269,7 +271,7 @@ runHost discovered accepted handleHttp = do serviceAddress <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ Discovery.connectRevHTTP2 serviceAddress fingerprint handleHttp -- spawn server for remote protocol commands + server <- async $ connectRevHTTP2 serviceAddress fingerprint handleHttp -- spawn server for remote protocol commands chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True _ <- waitCatch server -- wait for the server to finish diff --git a/src/Simplex/Chat/Remote/Discovery.hs b/src/Simplex/Chat/Remote/Discovery.hs deleted file mode 100644 index 1ede108b07..0000000000 --- a/src/Simplex/Chat/Remote/Discovery.hs +++ /dev/null @@ -1,236 +0,0 @@ -{-# LANGUAGE BlockArguments #-} -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE FlexibleContexts #-} -{-# LANGUAGE GADTs #-} -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE PatternSynonyms #-} - -module Simplex.Chat.Remote.Discovery where - -import Control.Logger.Simple -import Control.Monad -import Crypto.Random (getRandomBytes) -import Data.ByteString (ByteString) -import qualified Data.ByteString.Base64.URL as B64U -import Data.Default (def) -import Data.String (IsString) -import Data.Text (Text) -import Data.Text.Encoding (decodeUtf8) -import Data.Time.Clock.System (getSystemTime) -import Data.Word (Word16) -import qualified Network.Socket as N -import qualified Network.TLS as TLS -import qualified Network.UDP as UDP -import Simplex.Chat.Remote.Multicast (setMembership) -import Simplex.Chat.Remote.Types -import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Encoding (Encoding (..)) -import Simplex.Messaging.Encoding.String (StrEncoding (..)) -import Simplex.Messaging.Transport (supportedParameters) -import qualified Simplex.Messaging.Transport as Transport -import Simplex.Messaging.Transport.Client (TransportHost (..), defaultTransportClientConfig, runTransportClient) -import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) -import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) -import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) -import Simplex.Messaging.Transport.Server (defaultTransportServerConfig, runTransportServerSocket, startTCPServer) -import Simplex.Messaging.Util (ifM, tshow) -import Simplex.Messaging.Version (mkVersionRange) -import UnliftIO -import UnliftIO.Concurrent - --- | mDNS multicast group -pattern MULTICAST_ADDR_V4 :: (IsString a, Eq a) => a -pattern MULTICAST_ADDR_V4 = "224.0.0.251" - -pattern ANY_ADDR_V4 :: (IsString a, Eq a) => a -pattern ANY_ADDR_V4 = "0.0.0.0" - -pattern DISCOVERY_PORT :: (IsString a, Eq a) => a -pattern DISCOVERY_PORT = "5227" - -startSession :: MonadIO m => Maybe Text -> (N.HostAddress, Word16) -> C.KeyHash -> m ((C.APublicDhKey, C.APrivateDhKey), C.PrivateKeyEd25519, Announce, SignedOOB) -startSession deviceName serviceAddress caFingerprint = liftIO $ do - sessionStart <- getSystemTime - dh@(C.APublicDhKey C.SX25519 sessionDH, _) <- C.generateDhKeyPair C.SX25519 - (C.APublicVerifyKey C.SEd25519 sigPubKey, C.APrivateSignKey C.SEd25519 sigSecretKey) <- C.generateSignatureKeyPair C.SEd25519 - let - announce = - Announce - { versionRange = announceVersionRange, - sessionStart, - announceCounter = 0, - serviceAddress, - caFingerprint, - sessionDH, - announceKey = sigPubKey - } - authToken <- decodeUtf8 . B64U.encode <$> getRandomBytes 12 - let - oob = - OOB - { caFingerprint, - authToken, - host = decodeUtf8 . strEncode $ THIPv4 . N.hostAddressToTuple $ fst serviceAddress, - port = snd serviceAddress, - version = mkVersionRange 1 1, - appName = "simplex-chat", - sigPubKey, - deviceName - } - pure (dh, sigSecretKey, announce, signOOB sigSecretKey oob) - -getLocalAddress :: MonadIO m => TMVar Int -> m (Maybe N.HostAddress) -getLocalAddress subscribers = liftIO $ do - probe <- mkIpProbe - let bytes = smpEncode probe - withListener subscribers $ \receiver -> - withSender $ \sender -> do - UDP.send sender bytes - let expect = do - UDP.recvFrom receiver >>= \case - (p, _) | p /= bytes -> expect - (_, UDP.ClientSockAddr (N.SockAddrInet _port host) _cmsg) -> pure host - (_, UDP.ClientSockAddr _badAddr _) -> error "receiving from IPv4 socket" - timeout 1000000 expect - -mkIpProbe :: MonadIO m => m IpProbe -mkIpProbe = do - randomNonce <- liftIO $ getRandomBytes 32 - pure IpProbe {versionRange = ipProbeVersionRange, randomNonce} - --- | Announce tls server, wait for connection and attach http2 client to it. --- --- Announcer is started when TLS server is started and stopped when a connection is made. -announceRevHTTP2 :: Tasks -> (C.PrivateKeyEd25519, Announce) -> TLS.Credentials -> IO () -> IO (Either HTTP2ClientError HTTP2Client) -announceRevHTTP2 tasks (sigKey, announce@Announce {caFingerprint, serviceAddress=(host, _port)}) credentials finishAction = do - httpClient <- newEmptyMVar - started <- newEmptyTMVarIO - finished <- newEmptyMVar - _ <- forkIO $ readMVar finished >> finishAction -- attach external cleanup action to session lock - announcer <- async . liftIO $ atomically (takeTMVar started) >>= \case - Nothing -> pure () -- TLS server failed to start, skipping announcer - Just givenPort -> do - logInfo $ "Starting announcer for " <> ident <> " at " <> tshow (host, givenPort) - runAnnouncer (sigKey, announce {serviceAddress = (host, fromIntegral givenPort)}) - tasks `registerAsync` announcer - tlsServer <- startTLSServer started credentials $ \tls -> do - logInfo $ "Incoming connection for " <> ident - cancel announcer - runHTTP2Client finished httpClient tls `catchAny` (logError . tshow) - logInfo $ "Client finished for " <> ident - -- BUG: this should be handled in HTTP2Client wrapper, partially handled in startTLSServer - _ <- forkIO $ waitCatch tlsServer >> void (tryPutMVar finished ()) - tasks `registerAsync` tlsServer - logInfo $ "Waiting for client for " <> ident - readMVar httpClient - where - ident = decodeUtf8 $ strEncode caFingerprint - --- | Send replay-proof announce datagrams -runAnnouncer :: (C.PrivateKeyEd25519, Announce) -> IO () -runAnnouncer (announceKey, initialAnnounce) = withSender $ loop initialAnnounce - where - loop announce sock = do - UDP.send sock $ smpEncode (signAnnounce announceKey announce) - threadDelay 1000000 - loop announce {announceCounter = announceCounter announce + 1} sock - -startTLSServer :: (MonadUnliftIO m) => TMVar (Maybe N.PortNumber) -> TLS.Credentials -> (Transport.TLS -> IO ()) -> m (Async ()) -startTLSServer started credentials server = async . liftIO $ do - startedOk <- newEmptyTMVarIO - bracketOnError (startTCPServer startedOk "0") (\_e -> void . atomically $ tryPutTMVar started Nothing) $ \socket -> - ifM - (atomically $ readTMVar startedOk) - do - port <- N.socketPort socket - logInfo $ "System-assigned port: " <> tshow port - atomically $ putTMVar started (Just port) - runTransportServerSocket startedOk (pure socket) "RCP TLS" serverParams defaultTransportServerConfig server - (void . atomically $ tryPutTMVar started Nothing) - where - serverParams = - def - { TLS.serverWantClientCert = False, - TLS.serverShared = def {TLS.sharedCredentials = credentials}, - TLS.serverHooks = def, - TLS.serverSupported = supportedParameters - } - --- | Attach HTTP2 client and hold the TLS until the attached client finishes. -runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () -runHTTP2Client finishedVar clientVar tls = - ifM (isEmptyMVar clientVar) - attachClient - (logError "HTTP2 session already started on this listener") - where - attachClient = do - client <- attachHTTP2Client config ANY_ADDR_V4 DISCOVERY_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls - putMVar clientVar client - readMVar finishedVar - -- TODO connection timeout - config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} - -withSender :: MonadUnliftIO m => (UDP.UDPSocket -> m a) -> m a -withSender = bracket (liftIO $ UDP.clientSocket MULTICAST_ADDR_V4 DISCOVERY_PORT False) (liftIO . UDP.close) - -withListener :: MonadUnliftIO m => TMVar Int -> (UDP.ListenSocket -> m a) -> m a -withListener subscribers = bracket (openListener subscribers) (closeListener subscribers) - -openListener :: MonadIO m => TMVar Int -> m UDP.ListenSocket -openListener subscribers = liftIO $ do - sock <- UDP.serverSocket (MULTICAST_ADDR_V4, read DISCOVERY_PORT) - logDebug $ "Discovery listener socket: " <> tshow sock - let raw = UDP.listenSocket sock - -- N.setSocketOption raw N.Broadcast 1 - joinMulticast subscribers raw (listenerHostAddr4 sock) - pure sock - -closeListener :: MonadIO m => TMVar Int -> UDP.ListenSocket -> m () -closeListener subscribers sock = liftIO $ - partMulticast subscribers (UDP.listenSocket sock) (listenerHostAddr4 sock) `finally` UDP.stop sock - -joinMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () -joinMulticast subscribers sock group = do - now <- atomically $ takeTMVar subscribers - when (now == 0) $ do - setMembership sock group True >>= \case - Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) - Right () -> atomically $ putTMVar subscribers (now + 1) - -partMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () -partMulticast subscribers sock group = do - now <- atomically $ takeTMVar subscribers - when (now == 1) $ - setMembership sock group False >>= \case - Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) - Right () -> atomically $ putTMVar subscribers (now - 1) - -listenerHostAddr4 :: UDP.ListenSocket -> N.HostAddress -listenerHostAddr4 sock = case UDP.mySockAddr sock of - N.SockAddrInet _port host -> host - _ -> error "MULTICAST_ADDR_V4 is V4" - -recvAnnounce :: (MonadIO m) => UDP.ListenSocket -> m (N.SockAddr, ByteString) -recvAnnounce sock = liftIO $ do - (invite, UDP.ClientSockAddr source _cmsg) <- UDP.recvFrom sock - pure (source, invite) - -connectRevHTTP2 :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (HTTP2Request -> m ()) -> m () -connectRevHTTP2 serviceAddress fingerprint = connectTLSClient serviceAddress fingerprint . attachHTTP2Server - -connectTLSClient :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (Transport.TLS -> m a) -> m a -connectTLSClient (host, port) caFingerprint = runTransportClient defaultTransportClientConfig Nothing host (show port) (Just caFingerprint) - -attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () -attachHTTP2Server processRequest tls = do - withRunInIO $ \unlift -> - runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do - reqBody <- getHTTP2Body r doNotPrefetchHead - unlift $ processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} - --- | Suppress storing initial chunk in bodyHead, forcing clients and servers to stream chunks -doNotPrefetchHead :: Int -doNotPrefetchHead = 0 diff --git a/src/Simplex/Chat/Remote/RevHTTP.hs b/src/Simplex/Chat/Remote/RevHTTP.hs new file mode 100644 index 0000000000..c6c777596a --- /dev/null +++ b/src/Simplex/Chat/Remote/RevHTTP.hs @@ -0,0 +1,54 @@ +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Remote.RevHTTP where + +import Simplex.RemoteControl.Discovery +import Simplex.RemoteControl.Types +import Control.Logger.Simple +import Data.Word (Word16) +import qualified Network.TLS as TLS +import qualified Simplex.Messaging.Crypto as C +import qualified Simplex.Messaging.Transport as Transport +import Simplex.Messaging.Transport.Client (TransportHost (..)) +import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) +import Simplex.Messaging.Util (ifM) +import UnliftIO + +announceRevHTTP2 :: MonadUnliftIO m => Tasks -> (C.PrivateKeyEd25519, Announce) -> TLS.Credentials -> m () -> m (Either HTTP2ClientError HTTP2Client) +announceRevHTTP2 = announceCtrl runHTTP2Client + +-- | Attach HTTP2 client and hold the TLS until the attached client finishes. +runHTTP2Client :: MVar () -> MVar (Either HTTP2ClientError HTTP2Client) -> Transport.TLS -> IO () +runHTTP2Client finishedVar clientVar tls = + ifM (isEmptyMVar clientVar) + attachClient + (logError "HTTP2 session already started on this listener") + where + attachClient = do + client <- attachHTTP2Client config ANY_ADDR_V4 DISCOVERY_PORT (putMVar finishedVar ()) defaultHTTP2BufferSize tls + putMVar clientVar client + readMVar finishedVar + -- TODO connection timeout + config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} + +connectRevHTTP2 :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (HTTP2Request -> m ()) -> m () +connectRevHTTP2 serviceAddress fingerprint = connectTLSClient serviceAddress fingerprint . attachHTTP2Server + +attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () +attachHTTP2Server processRequest tls = do + withRunInIO $ \unlift -> + runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + reqBody <- getHTTP2Body r doNotPrefetchHead + unlift $ processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} + +-- | Suppress storing initial chunk in bodyHead, forcing clients and servers to stream chunks +doNotPrefetchHead :: Int +doNotPrefetchHead = 0 diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 4507c3de79..dcf70ab714 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -2,39 +2,22 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} -{-# LANGUAGE GADTs #-} module Simplex.Chat.Remote.Types where -import Control.Exception -import Control.Monad -import Crypto.Error (eitherCryptoError) -import qualified Crypto.PubKey.Ed25519 as Ed25519 +import Control.Exception (Exception) import qualified Data.Aeson.TH as J -import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.ByteArray (convert) -import Data.ByteString (ByteString) -import qualified Data.ByteString.Char8 as B -import Data.Foldable (toList) import Data.Int (Int64) import Data.Text (Text) -import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8) -import Data.Time.Clock.System (SystemTime) -import Data.Word (Word16) -import Network.HTTP.Types (parseSimpleQuery) -import Network.HTTP.Types.URI (renderSimpleQuery, urlDecode, urlEncode) -import qualified Network.Socket as N import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.File (CryptoFile) -import Simplex.Messaging.Encoding (Encoding (..)) -import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) -import Simplex.Messaging.Version (VersionRange, mkVersionRange) -import UnliftIO +import Simplex.RemoteControl.Types (Tasks) +import Simplex.Messaging.Crypto.File (CryptoFile) data RemoteHostClient = RemoteHostClient { hostEncoding :: PlatformEncoding, @@ -50,15 +33,19 @@ data RemoteHostSession = RemoteHostSession } data RemoteProtocolError - = RPEInvalidSize -- ^ size prefix is malformed - | RPEInvalidJSON {invalidJSON :: Text} -- ^ failed to parse RemoteCommand or RemoteResponse + = -- | size prefix is malformed + RPEInvalidSize + | -- | failed to parse RemoteCommand or RemoteResponse + RPEInvalidJSON {invalidJSON :: Text} | RPEIncompatibleEncoding | RPEUnexpectedFile | RPENoFile | RPEFileSize | RPEFileDigest - | RPEUnexpectedResponse {response :: Text} -- ^ Wrong response received for the command sent - | RPEStoredFileExists -- ^ A file already exists in the destination position + | -- | Wrong response received for the command sent + RPEUnexpectedResponse {response :: Text} + | -- | A file already exists in the destination position + RPEStoredFileExists | RPEHTTP2 {http2Error :: Text} | RPEException {someException :: Text} deriving (Show, Exception) @@ -110,169 +97,6 @@ data RemoteCtrlInfo = RemoteCtrlInfo } deriving (Show) -data RemoteFile = RemoteFile - { userId :: Int64, - fileId :: Int64, - sent :: Bool, - fileSource :: CryptoFile - } - deriving (Show) - -ipProbeVersionRange :: VersionRange -ipProbeVersionRange = mkVersionRange 1 1 - -data IpProbe = IpProbe - { versionRange :: VersionRange, - randomNonce :: ByteString - } deriving (Show) - -instance Encoding IpProbe where - smpEncode IpProbe {versionRange, randomNonce} = smpEncode (versionRange, 'I', randomNonce) - - smpP = IpProbe <$> (smpP <* "I") *> smpP - -announceVersionRange :: VersionRange -announceVersionRange = mkVersionRange 1 1 - -data Announce = Announce - { versionRange :: VersionRange, - sessionStart :: SystemTime, - announceCounter :: Word16, - serviceAddress :: (N.HostAddress, Word16), - caFingerprint :: C.KeyHash, - sessionDH :: C.PublicKeyX25519, - announceKey :: C.PublicKeyEd25519 - } deriving (Show) - -instance Encoding Announce where - smpEncode Announce {versionRange, sessionStart, announceCounter, serviceAddress, caFingerprint, sessionDH, announceKey} = - smpEncode (versionRange, 'A', sessionStart, announceCounter, serviceAddress) - <> smpEncode (caFingerprint, sessionDH, announceKey) - - smpP = Announce <$> (smpP <* "A") <*> smpP <*> smpP <*> smpP <*> smpP <*> smpP <*> smpP - -data SignedAnnounce = SignedAnnounce Announce (C.Signature 'C.Ed25519) - -instance Encoding SignedAnnounce where - smpEncode (SignedAnnounce ann (C.SignatureEd25519 sig)) = smpEncode (ann, convert sig :: ByteString) - - smpP = do - sa <- SignedAnnounce <$> smpP <*> signatureP - unless (verifySignedAnnounce sa) $ fail "bad announce signature" - pure sa - where - signatureP = do - bs <- smpP :: A.Parser ByteString - case eitherCryptoError (Ed25519.signature bs) of - Left ce -> fail $ show ce - Right ok -> pure $ C.SignatureEd25519 ok - -signAnnounce :: C.PrivateKey C.Ed25519 -> Announce -> SignedAnnounce -signAnnounce announceSecret ann = SignedAnnounce ann sig - where - sig = - case C.sign (C.APrivateSignKey C.SEd25519 announceSecret) (smpEncode ann) of - C.ASignature C.SEd25519 s -> s - _ -> error "signing with ed25519" - -verifySignedAnnounce :: SignedAnnounce -> Bool -verifySignedAnnounce (SignedAnnounce ann@Announce {announceKey} sig) = C.verify aKey aSig (smpEncode ann) - where - aKey = C.APublicVerifyKey C.SEd25519 announceKey - aSig = C.ASignature C.SEd25519 sig - -data OOB = OOB - { -- authority part - caFingerprint :: C.KeyHash, - authToken :: Text, - host :: Text, - port :: Word16, - -- query part - version :: VersionRange, -- v= - appName :: Text, -- app= - sigPubKey :: C.PublicKeyEd25519, -- key= - deviceName :: Maybe Text -- device= - } - deriving (Eq, Show) - -instance StrEncoding OOB where - strEncode OOB {caFingerprint, authToken, host, port, version, appName, sigPubKey, deviceName} = - schema <> "://" <> authority <> "#/?" <> renderSimpleQuery False query - where - schema = "xrcp" - authority = - mconcat - [ strEncode caFingerprint, - ":", - encodeUtf8 authToken, - "@", - encodeUtf8 host, - ":", - strEncode port - ] - query = - [ ("v", strEncode version), - ("app", encodeUtf8 appName), - ("key", strEncode $ C.encodePubKey sigPubKey) - ] - ++ [("device", urlEncode True $ encodeUtf8 name) | name <- toList deviceName] - - strP = do - _ <- A.string "xrcp://" - caFingerprint <- strP - _ <- A.char ':' - authToken <- decodeUtf8Lenient <$> A.takeWhile (/= '@') - _ <- A.char '@' - host <- decodeUtf8Lenient <$> A.takeWhile (/= ':') - _ <- A.char ':' - port <- strP - - _ <- A.string "#/?" - q <- parseSimpleQuery <$> A.takeByteString - version <- maybe (fail "missing version") (either fail pure . strDecode) (lookup "v" q) - appName <- maybe (fail "missing appName") (pure . decodeUtf8Lenient) (lookup "app" q) - sigPubKeyB64 <- maybe (fail "missing key") pure (lookup "key" q) - sigPubKey <- either fail pure $ strDecode sigPubKeyB64 >>= C.decodePubKey - let deviceName = fmap (decodeUtf8Lenient . urlDecode True) (lookup "device" q) - pure OOB {caFingerprint, authToken, host, port, version, appName, sigPubKey, deviceName} - -data SignedOOB = SignedOOB OOB (C.Signature 'C.Ed25519) - deriving (Eq, Show) - -instance StrEncoding SignedOOB where - strEncode (SignedOOB oob sig) = strEncode oob <> "&sig=" <> strEncode (C.signatureBytes sig) - - strDecode s = do - unless (B.length sig == sigLen) $ Left "bad size" - unless ("&sig=" `B.isPrefixOf` sig) $ Left "bad signature prefix" - signedOOB <- SignedOOB <$> strDecode oob <*> (strDecode (B.drop 5 sig) >>= C.decodeSignature) - unless (verifySignedOOB signedOOB) $ Left "bad signature" - pure signedOOB - where - l = B.length s - (oob, sig) = B.splitAt (l - sigLen) s - sigLen = 93 -- &sig= + ed25519 sig size in base64 (88) - - -- XXX: strP is used in chat command parser, but default strP assumes bas64url-encoded bytestring, where OOB is an URL-like - strP = A.takeWhile (/= ' ') >>= either fail pure . strDecode - -signOOB :: C.PrivateKey C.Ed25519 -> OOB -> SignedOOB -signOOB key oob = SignedOOB oob sig - where - sig = - case C.sign (C.APrivateSignKey C.SEd25519 key) (strEncode oob) of - C.ASignature C.SEd25519 s -> s - _ -> error "signing with ed25519" - -verifySignedOOB :: SignedOOB -> Bool -verifySignedOOB (SignedOOB oob@OOB {sigPubKey} sig) = C.verify aKey aSig (strEncode oob) - where - aKey = C.APublicVerifyKey C.SEd25519 sigPubKey - aSig = C.ASignature C.SEd25519 sig - -decodeOOBLink :: Text -> Either String OOB -decodeOOBLink = fmap (\(SignedOOB oob _verified) -> oob) . strDecode . encodeUtf8 - data PlatformEncoding = PESwift | PEKotlin @@ -285,16 +109,15 @@ localEncoding = PESwift localEncoding = PEKotlin #endif -type Tasks = TVar [Async ()] +data RemoteFile = RemoteFile + { userId :: Int64, + fileId :: Int64, + sent :: Bool, + fileSource :: CryptoFile + } + deriving (Show) -asyncRegistered :: MonadUnliftIO m => Tasks -> m () -> m () -asyncRegistered tasks action = async action >>= registerAsync tasks - -registerAsync :: MonadIO m => Tasks -> Async () -> m () -registerAsync tasks = atomically . modifyTVar tasks . (:) - -cancelTasks :: (MonadIO m) => Tasks -> m () -cancelTasks tasks = readTVarIO tasks >>= mapM_ cancel +$(J.deriveJSON defaultJSON ''RemoteFile) $(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) @@ -305,5 +128,3 @@ $(J.deriveJSON defaultJSON ''RemoteHostInfo) $(J.deriveJSON defaultJSON ''RemoteCtrl) $(J.deriveJSON defaultJSON ''RemoteCtrlInfo) - -$(J.deriveJSON defaultJSON ''RemoteFile) diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 0dfe665b2f..df7ccd499b 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -15,6 +15,7 @@ import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.RemoteControl.Types insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId insertRemoteHost db storePath displayName caKey caCert = do diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index ccbd543e9a..7c62333d66 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -5,6 +5,9 @@ module RemoteTests where +import Simplex.Chat.Remote.RevHTTP +import qualified Simplex.RemoteControl.Discovery as Discovery +import Simplex.RemoteControl.Types import ChatClient import ChatTests.Utils import Control.Logger.Simple @@ -23,13 +26,11 @@ import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File -import qualified Simplex.Chat.Remote.Discovery as Discovery import Simplex.Chat.Remote.Types import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding (smpDecode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) -import qualified Simplex.Messaging.Transport as Transport import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) @@ -43,9 +44,9 @@ import UnliftIO.Directory remoteTests :: SpecWith FilePath remoteTests = describe "Remote" $ do - it "generates usable credentials" genCredentialsTest + -- it "generates usable credentials" genCredentialsTest + -- it "OOB encoding, decoding, and signatures are correct" oobCodecTest it "connects announcer with discoverer over reverse-http2" announceDiscoverHttp2Test - it "OOB encoding, decoding, and signatures are correct" oobCodecTest it "performs protocol handshake" remoteHandshakeTest it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check it "sends messages" remoteMessageTest @@ -55,38 +56,39 @@ remoteTests = describe "Remote" $ do -- * Low-level TLS with ephemeral credentials -genCredentialsTest :: (HasCallStack) => FilePath -> IO () -genCredentialsTest _tmp = do - (fingerprint, credentials) <- genTestCredentials - started <- newEmptyTMVarIO - bracket (Discovery.startTLSServer started credentials serverHandler) cancel $ \_server -> do - ok <- atomically (readTMVar started) - port <- maybe (error "TLS server failed to start") pure ok - logNote $ "Assigned port: " <> tshow port - Discovery.connectTLSClient ("127.0.0.1", fromIntegral port) fingerprint clientHandler - where - serverHandler serverTls = do - logNote "Sending from server" - Transport.putLn serverTls "hi client" - logNote "Reading from server" - Transport.getLn serverTls `shouldReturn` "hi server" - clientHandler clientTls = do - logNote "Sending from client" - Transport.putLn clientTls "hi server" - logNote "Reading from client" - Transport.getLn clientTls `shouldReturn` "hi client" +-- -- XXX: extract +-- genCredentialsTest :: (HasCallStack) => FilePath -> IO () +-- genCredentialsTest _tmp = do +-- (fingerprint, credentials) <- genTestCredentials +-- started <- newEmptyTMVarIO +-- bracket (startTLSServer started credentials serverHandler) cancel $ \_server -> do +-- ok <- atomically (readTMVar started) +-- port <- maybe (error "TLS server failed to start") pure ok +-- logNote $ "Assigned port: " <> tshow port +-- connectTLSClient ("127.0.0.1", fromIntegral port) fingerprint clientHandler +-- where +-- serverHandler serverTls = do +-- logNote "Sending from server" +-- Transport.putLn serverTls "hi client" +-- logNote "Reading from server" +-- Transport.getLn serverTls `shouldReturn` "hi server" +-- clientHandler clientTls = do +-- logNote "Sending from client" +-- Transport.putLn clientTls "hi server" +-- logNote "Reading from client" +-- Transport.getLn clientTls `shouldReturn` "hi client" -- * UDP discovery and rever HTTP2 -oobCodecTest :: (HasCallStack) => FilePath -> IO () -oobCodecTest _tmp = do - subscribers <- newTMVarIO 0 - localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure - (fingerprint, _credentials) <- genTestCredentials - (_dhKey, _sigKey, _ann, signedOOB@(SignedOOB oob _sig)) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint - verifySignedOOB signedOOB `shouldBe` True - strDecode (strEncode oob) `shouldBe` Right oob - strDecode (strEncode signedOOB) `shouldBe` Right signedOOB +-- oobCodecTest :: (HasCallStack) => FilePath -> IO () +-- oobCodecTest _tmp = do +-- subscribers <- newTMVarIO 0 +-- localAddr <- Discovery.getLocalAddress subscribers >>= maybe (fail "unable to get local address") pure +-- (fingerprint, _credentials) <- genTestCredentials +-- (_dhKey, _sigKey, _ann, signedOOB@(SignedOOB oob _sig)) <- Discovery.startSession (Just "Desktop") (localAddr, read Discovery.DISCOVERY_PORT) fingerprint +-- verifySignedOOB signedOOB `shouldBe` True +-- strDecode (strEncode oob) `shouldBe` Right oob +-- strDecode (strEncode signedOOB) `shouldBe` Right signedOOB announceDiscoverHttp2Test :: (HasCallStack) => FilePath -> IO () announceDiscoverHttp2Test _tmp = do @@ -99,7 +101,7 @@ announceDiscoverHttp2Test _tmp = do controller <- async $ do logNote "Controller: starting" bracket - (Discovery.announceRevHTTP2 tasks (sigKey, ann) credentials (putMVar finished ()) >>= either (fail . show) pure) + (announceRevHTTP2 tasks (sigKey, ann) credentials (putMVar finished ()) >>= either (fail . show) pure) closeHTTP2Client ( \http -> do logNote "Controller: got client" @@ -119,7 +121,7 @@ announceDiscoverHttp2Test _tmp = do logNote $ "Host: connecting to " <> tshow service server <- async $ Discovery.connectTLSClient service fingerprint $ \tls -> do logNote "Host: got tls" - flip Discovery.attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do + flip attachHTTP2Server tls $ \HTTP2Request {sendResponse} -> do logNote "Host: got request" sendResponse $ S.responseNoBody ok200 [] logNote "Host: sent response" From c8c17a2f68ebc87ae5554be1d1ecf2f610f1f176 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 1 Nov 2023 13:10:19 +0000 Subject: [PATCH 132/294] core: fix uri parse to not include trailing punctuation in URIs (#3296) * core: fix uri parse to not include trailing punctuation in URIs * simplify --- src/Simplex/Chat/Markdown.hs | 12 ++++++++---- tests/MarkdownTests.hs | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 30990f225d..969c7c2b56 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -14,7 +14,7 @@ import Data.Aeson (ToJSON) import qualified Data.Aeson as J import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A -import Data.Char (isDigit) +import Data.Char (isDigit, isPunctuation) import Data.Either (fromRight) import Data.Functor (($>)) import Data.List (intercalate, foldl') @@ -217,11 +217,15 @@ markdownP = mconcat <$> A.many' fragmentP wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s - | isUri s = case strDecode $ encodeUtf8 s of - Right cReq -> markdown (simplexUriFormat cReq) s - _ -> markdown Uri s + | isUri s = + let t = T.takeWhileEnd isPunctuation s + uri = uriMarkdown $ T.dropWhileEnd isPunctuation s + in if T.null t then uri else uri :|: unmarked t | isEmail s = markdown Email s | otherwise = unmarked s + uriMarkdown s = case strDecode $ encodeUtf8 s of + Right cReq -> markdown (simplexUriFormat cReq) s + _ -> markdown Uri s isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s) noFormat = pure . unmarked diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 83a180c745..1cd2aa2c47 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -144,6 +144,8 @@ textWithUri :: Spec textWithUri = describe "text with Uri" do it "correct markdown" do parseMarkdown "https://simplex.chat" `shouldBe` uri "https://simplex.chat" + parseMarkdown "https://simplex.chat." `shouldBe` uri "https://simplex.chat" <> "." + parseMarkdown "https://simplex.chat, hello" `shouldBe` uri "https://simplex.chat" <> ", hello" parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat" parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat" parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site" From c1a0486c1dbd518b01fcb4ce3be6debcc92d2fbe Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 1 Nov 2023 17:30:19 +0400 Subject: [PATCH 133/294] docs: groups integrity rfc (#3128) --- docs/rfcs/2023-09-25-groups-integrity.md | 124 +++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/rfcs/2023-09-25-groups-integrity.md diff --git a/docs/rfcs/2023-09-25-groups-integrity.md b/docs/rfcs/2023-09-25-groups-integrity.md new file mode 100644 index 0000000000..c7f0d99f93 --- /dev/null +++ b/docs/rfcs/2023-09-25-groups-integrity.md @@ -0,0 +1,124 @@ +# Groups integrity + +## Problems + +- Inconsistency of group state: + - group profile including group wide preferences, + - list of members and their roles. +- Lack of group messages integrity - group member can send different messages to different members. + +Lack of group consistency leads to group federation both in terms of members list and content visible to different members, which leads to user frustration and lack of trust. + +Improvements to group design should provide: + +- Consistent group state. +- Group messages integrity: + - integrity violations (different message sent to different members) should be identified and shown to users, + - missed messages should be requested to fill in gaps. + +## Design ideas and questions + +### Group messages integrity + +A message container to include member's message ID (ordered?), and list of IDs and hashes of parent messages. + +```haskell +data MsgParentId = MsgParentId + { memberId :: MemberId, + msgId :: Int64, -- sequential message ID for parent message (among memberId member messages) + msgHash :: ByteString + } + +data MsgIds = MsgIds + { msgId :: Int64, -- sequential message ID for member's message + parentIds :: [MsgParentId] + } +``` + +Questions: + - What level of protocol should include MsgIds, and what messages should be included into integrity graph? + - Having it on AppMessage level would allow to include all protocol messages. But some protocol messages are sent with different content per member (XGrpMemIntro, XGrpMemFwd, probe messages) and would have different hash. Also they contain sensitive data such as invitation links and should not be forwarded anyway. + - If MsgIds is MsgContainer level, only XMsgNew would have it. This excludes other content messages such as updates, deletes, etc. + - Include it into specific "content" chat events - XMsgNew, XMsgFileCancel (unused), XMsgUpdate, XMsgDel, XMsgReact, XFile (not used anymore but was never fully deprecated), XFileCancel. + - Some new protocol level container, uniting above events? + - Should msgId be sequential integer? (It leaks metadata about member's previous activity in the group) Can SharedMsgId be used instead? + - Depending on number of parent messages, parentIds can become arbitrarily long and not fit into 16KB block, especially for messages containing profiles pictures. + +When receiving a message with unknown parent identifiers, client should request missing messages from the sender by sending XGrpRequestSkipped, including last seen message reference for each missing parent. When receiving XGrpRequestSkipped, member should forward requested messages up to last seen parent using XGrpRequested. + +```haskell +-- include received parentId? +XGrpRequestSkipped :: [MsgParentId] -> ChatMsgEvent 'Json + +data MsgRequestedParent = MsgRequested + { parentId :: MsgParentId, + msg :: MsgContainer -- content TBD based on scope of messages included into integrity graph. Full event? + } + +XGrpRequested :: MsgRequestedParent -> ChatMsgEvent 'Json +``` + +Questions: + - Depending on number of missing parents, XGrpRequestSkipped may not fit into 16KB block. + - There may be multiple skipped messages for a given member, should they be sent sequentially from oldest (following the one known to requesting member) to newest? + - XGrpRequested may not fit into 16KB block even if original MsgContainer / chat event did fit. On the other hand multiple XGrpRequested messages can be batched. + - Malicious group member may arbitrarily request (at any time or in response to a new message) any number of skipped messages by sending parentIds from the past and trigger receiving member to send a lot of traffic. There are already some automatic response events in protocol, but they are harder to abuse: XGrpMemFwd - requires cooperation with other member, or creating connection; receipts - can be turned off; probes - requires member having matching contact and being non incognito in group. Should the member receiving XGrpRequestSkipped protect from such abuse by limiting number of requested messages? Limiting number or requests from a specific member in time? + - By the time member requests skipped messages, sender may be offline. Should the requester send XGrpRequestSkipped to other members? + - together with the request to sender or after some period? + - to which members? - fraction of admins? all admins? + - Member receiving XGrpRequestSkipped may not have requested messages, for example: + - request is for the older parent id, and member never received it himself (was not part of the group then or has gap in place of this message), or has gap between sent message parent and requested parent. + - member deleted parent(s), e.g. via periodic cleanup, or by deleting specific messages. + - don't fully delete group message records while in group? instead only overwrite content? + +Message integrity is computed for received messages, can be updated on receiving requested message parents. + +```haskell +data GroupMsgIntegrity + = GMIOk + | GMISkippedParents {skippedParents :: [MsgParentId]} + | GMIBadParentHash {knownParent :: MsgParentId, badParent :: MsgParentId} -- list? +``` + +```sql +CREATE TABLE message_integrity_records( -- message_hashes? group_messages? + message_integrity_record_id INTEGER PRIMARY KEY, + message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, -- SET NULL? + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + member_id BLOB NOT NULL, + member_msg_id INTEGER NOT NULL, -- shared_msg_id? + msg_hash BLOB NOT NULL, + msg_integrity TEXT NOT NULL, -- computed for received messages, for sent always Ok? + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +-- many to many table for message_integrity_records table +-- (parent can have multiple children, child can have multiple parents) +-- parent can be null if it wasn't received +CREATE TABLE message_parents( + message_parent_id INTEGER PRIMARY KEY, + message_integrity_record_id INTEGER NOT NULL REFERENCES message_integrity_record_id ON DELETE CASCADE, + message_parent_integrity_record_id INTEGER REFERENCES message_integrity_record_id ON DELETE CASCADE, + msg_parent_member_id BLOB NOT NULL, + msg_parent_member_msg_id INTEGER NOT NULL, + msg_hash BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +``` + +How should message integrity errors be displayed in UI? + - Displaying skipped parent errors would clutter UI due to delays in delivery. Probably they shouldn't be displayed. + - Integrity violations (hashes not matching) should be displayed on respective chat items. + - if integrity is on AppMessage level for all chat events - not all messages have corresponding chat items, create internal chat items? + - if it's on the level of content messages, updates / etc. can be high above in message history, deletes can be not visible at all (full delete). + - how to get reference to message via chat item when loading chat items? Integrity violation can be on a message different than chat item's created_by_msg_id message. For each chat item load integrity of all messages via chat_item_messages? + - If integrity errors are only displayed on integrity violations, for malicious member to work around it and send different message to different group members could he specify unknown (far into future or past) message id, instead of incorrect one? Sender then wouldn't respond with skipped parents (and other members wouldn't be able to) - how to differentiate between this case and skipper parent error that is to be ignored in UI? + - Should it be prohibited to not send MsgIds (to avoid message integrity check) if member protocol version supports it? Should it be prohibited at all and group with integrity be separated? How to distinguish between messages sent without integrity fields and messages with skipped parents in UI? + - Not showing skipped parents integrity error in UI would lead user to believe integrity is preserved, and integrity violation can be revealed later. If conversation is time sensitive member may react to message considering it conversation integrity wasn't breached, and integrity violation may be revealed later. Having eventual integrity may not be better than having no integrity at all, and may even be worse because it produces false assumptions regarding conversation integrity. The goal can be narrowed to only restoring missed messages (gaps), without calculating integrity. + +### Consistent group state + +TODO From 68873464d7f899f14bee616aea404cc42c1b1382 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 1 Nov 2023 17:30:40 +0400 Subject: [PATCH 134/294] docs: groups integrity DAGs rfc (#3258) --- docs/rfcs/2023-10-20-group-integrity.md | 229 ++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/rfcs/2023-10-20-group-integrity.md diff --git a/docs/rfcs/2023-10-20-group-integrity.md b/docs/rfcs/2023-10-20-group-integrity.md new file mode 100644 index 0000000000..0a0e7ef295 --- /dev/null +++ b/docs/rfcs/2023-10-20-group-integrity.md @@ -0,0 +1,229 @@ +# Group integrity + +3 level of DAGs: + +Owner + - group profile and permissions, admin invites and removals + - in case of gap vote before applying event + +Admin + - member invites and removals + - prohibit to add and remove admins + - in case of gap most destructive wins + - link to owner dag + +Messages + - in case of gap show history according to local graph, correct when owner or admin dag changes + - link to both admin and owner dags + +```haskell +-- protocol +data MsgParent = MsgParent + { memberId :: MemberId, + memberName :: String, -- recipient can use to display message if they don't have member introduced; + -- optional? + sharedMsgId :: SharedMsgId, + msgHash :: ByteString, + msgBody :: String? -- recipient can use to display message in case parent wasn't yet received; + -- sender can pack as many parents as fits into block + stored :: Bool -- whether sender has message stored, and it can be requested + } + +data MsgIds = MsgIds -- include into chat event + { sharedMsgId :: SharedMsgId, + ownerDAGMsgId :: SharedMsgId, -- list of parents? + adminDAGMsgId :: SharedMsgId, + parents :: [MsgParent] + } + +-- model +data OwnerDAGEventParent + = ODEPKnown {eventId :: ?} -- DB id? sharedMsgId? + | ODEPUnknown {eventId :: ?} + +data OwnerDAGEvent = DAGEvent + { eventId :: ?, + parents :: [OwnerDAGEventParent] + } + +data AdminDAGEventParent + = ADEPKnown {eventId :: ?} + | ADEPUnknown {eventId :: ?} + +data AdminDAGEvent = DAGEvent + { eventId :: ?, + ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ? + parents :: [AdminDAGEventParent] + } + +data MessagesDAGEventParent + = MDEPKnown {eventId :: ?} + | MDEPUnknown {eventId :: ?} + +data MessagesDAGEvent = DAGEvent + { eventId :: ?, + ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ? + adminDAGEventId :: ?, -- [AdminDAGEventParent] - parentIds? ? + parents :: [MessagesDAGEventParent] + } +``` + +How to restore from destructive messages? +Even if all message parents are known, destructive logic of message should be applied after other members refer it. + +How to workaround members maliciously referring non-existent parents? +For example, this can lead to an owner preventing group updates. + +``` +-- should dag be maintained in memory? older events to be removed +-- read on event? +-- how long into past to get dag? + +ClassifiedEvent = OwnerEvent | AdminEvent | MsgEvent + +def processEvent(e: Event) = + classifiedEvent <- classifyEvent(e) + case classifiedEvent of + OwnerEvent oe -> processOwnerEvent(oe) + AdminEvent ae -> processAdminEvent(ae) + MsgEvent me -> processMsgEvent(me) + +def classifyEvent(e: Event) -> ClassifiedEvent? = + case e of + XMsgNew -> MsgEvent + XMsgFileDescr -> Nothing -- different per member + XMsgFileCancel -> MsgEvent + XMsgUpdate -> MsgEvent + XMsgDel -> MsgEvent + XMsgReact -> MsgEvent + XFile -> MsgEvent + XFileCancel -> MsgEvent + XFileAcptInv -> Nothing -- different per member + XGrpMemNew -> OwnerEvent -- sent by owner, new member is admin or owner + or AdminEvent -- sent by admin (or by owner and new member role is less than admin?) + -- problem: if member role changes, members can add event to different dags + -- what should define member role? + XGrpMemIntro -> Nothing -- received only by invitee + XGrpMemInv -> Nothing -- received only by host + XGrpMemFwd -> Nothing -- different per member; not received by invitee + XGrpMemRole -> OwnerEvent -- sent by owner about owner or admin + or AdminEvent -- sent by admin (or by owner about member with role less than admin?) + XGrpMemDel -> OwnerEvent -- sent by owner about owner or admin + or AdminEvent -- sent by admin (or by owner about member with role less than admin?) + XGrpLeave -> MsgEvent + XGrpDel -> OwnerEvent + XGrpInfo -> OwnerEvent + XGrpDirectInv -> Nothing -- received by single member + XInfoProbe -> Nothing -- per member + XInfoProbeCheck -> Nothing -- per member + XInfoProbeOk -> Nothing -- per member + BFileChunk -> Nothing -- could be MsgEvent? + _ -> Nothing -- not supported in groups + +-- # owner events + +def processOwnerEvent(oe: OwnerEvent) = + process every owner event after owners reach consensus + +// def processOwnerEvent(oe: OwnerEvent) = +// addOwnerDagEvent(oe) +// applyOwnerDagEvent(oe) +// +// def addOwnerDagEvent(oe: OwnerEvent) = +// if (any parent of oe not in dag): +// buffer until all parents are in ownerDag +// else +// add oe to ownerDag +// +// def applyOwnerDagEvent(oe: OwnerEvent) = +// case oe of +// -- process XGrpMemNew, XGrpMemRole, XGrpMemDel same as for admin dag (see below), or should vote for all events? +// XGrpMemNew -> ... +// XGrpMemRole -> ... +// XGrpMemDel -> ... +// -- how to vote - to depend on action (group - manual, update - automatic?); +// -- wait for voting always, or if event has unknown parents? (gaps in dag) +// -- how to treat delayed integrity violation - owner sending message to select members +// XGrpDel -> +// -- create "pending group deletion", wait for confirmation from majority of owners? +// -- new protocol requiring user action from other owners? +// XGrpInfo -> +// -- create "unconfirmed group profile update", remember prev group profile +// -- remove from "unconfirmed group profile update" when this event is in dag and not a leaf? +// -- if another group profile update event is received, revert "unconfirmed" event, don't apply new +// -- so if more than one update is received while dag is not merged to single vertice, all updates are not applied +// -- - this would likely lock out owners from any future updates +// -- - merge to new starting point after some time passes? +// -- - mark parents that are never received and so always block graph merging as special type? + +-- # admin events + +def processAdminEvent(ae: AdminEvent) = + lookup in owner dag - does member still have permission? + addAdminDagEvent(ae) + applyAdminDagEvent(ae) + +def addAdminDagEvent(ae: AdminEvent) = + if (any parent of ae not in dag): + buffer until all parents are in adminDag + else + add ae to adminDag + +def applyAdminDagEvent(ae: AdminEvent) = + case ae of + XGrpMemNew -> + -- handles case where messages from 2 admins about member addition and deletion arrive out of order + if member is not in "unconfirmed member deletions": + add member + XGrpMemRole -> + add role change to "unconfirmed role change" + -- remove from "unconfirmed role change" when this event is in dag and not a leaf? + if another role change already in "unconfirmed role change": + if new role is less than role in "unconfirmed role change": + change role -- role change applies in direction of lower role + XGrpMemDel -> + add member to "unconfirmed member deletions" + -- remove from "unconfirmed member deletions" when this event is in dag and not a leaf? + if member found by memberId: + delete member + +-- ^ problem: if later admin event turns out to fail integrity check, how to revert it? +-- member deletion: don't apply until in graph and not a leaf +-- role change: remember previous role and revert +-- member addition: delete member + +-- # message events + +def processMsgEvent(me: MsgEvent) = + lookup points in owner and admin dag? + - does member have permission to send event? (role changed/removed) + addMsgDagEvent(me) + applyMsgEvent(me) + +def addMsgDagEvent(me: MsgEvent) = + for me.parents not in msgDag: + add MDEPUnknown parent to msgDag + add me to msgDag + +def applyMsgEvent(me: MsgEvent) = + case me of + XMsgNew -> message to view + -- start process waiting for missing parents; if parents are not received: + -- can be shown as integrity violation if parents are not received + -- can be shown as integrity violation if other members don't refer it? + XMsgFileCancel -> cancel file immediately + -- wait for missing parents / referrals similarly to XMsgNew + -- restart file reception on integrity violation? + XMsgUpdate -> update to view -- same as XMsgNew + XMsgDel -> mark deleted, don't apply full delete until parents/referrals are received? + XMsgReact -> to view -- same as XMsgNew + XFile -> -- deprecate? + XFileCancel -> cancel -- same as XMsgFileCancel + XGrpLeave -> mark member as left, don't delete member connection immediately + -- member may try to maliciously remove connections selectively + -- wait for integrity check +``` + +# Admin blockchain + +Suppose admin DAG is replaced with blockchain, with a conflict resolution protocol to provide consistency of membership changes. Take Simplex (not to confuse with SimpleX chat) protocol (https://simplex.blog/). To reach BFT consensus and make progress, 2n/3 votes on block proposals are required, and it's assumed `f < n/3` where f is number of malicious actors. In a highly asynchronous setting of decentralized groups operated by mobile devices, progress seems unlikely or very slow. Should "admin participation" be hosted? From 4cc20a2d329d7ada46a81aa4a09f716a63d640ea Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 1 Nov 2023 21:52:45 +0800 Subject: [PATCH 135/294] android, desktop: block members (#3290) * android, desktop: block members * fixes * more fixes * fix * fix * color * color and icon --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/ChatModel.kt | 121 +++++- .../chat/simplex/common/model/SimpleXAPI.kt | 12 +- .../common/views/chat/ChatItemInfoView.kt | 2 +- .../simplex/common/views/chat/ChatView.kt | 156 +++++--- .../views/chat/group/GroupChatInfoView.kt | 53 ++- .../views/chat/group/GroupMemberInfoView.kt | 76 +++- .../views/chat/item/CIChatFeatureView.kt | 108 +++++- .../common/views/chat/item/CIEventView.kt | 9 +- .../common/views/chat/item/ChatItemView.kt | 358 ++++++++++++------ .../common/views/chat/item/FramedItemView.kt | 14 +- .../views/chat/item/MarkedDeletedItemView.kt | 62 ++- .../simplex/common/views/helpers/Section.kt | 28 ++ .../commonMain/resources/MR/base/strings.xml | 22 ++ .../resources/MR/images/ic_back_hand.svg | 1 + .../resources/MR/images/ic_collapse_all.svg | 1 + .../resources/MR/images/ic_do_not_touch.svg | 1 + .../resources/MR/images/ic_expand_all.svg | 1 + 17 files changed, 822 insertions(+), 203 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 767c678c1a..4d95bfd499 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -137,6 +137,7 @@ object ChatModel { fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } + fun getGroupMember(groupMemberId: Long): GroupMember? = groupMembers.firstOrNull { it.groupMemberId == groupMemberId } private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id } fun addChat(chat: Chat) = chats.add(index = 0, chat) @@ -442,6 +443,78 @@ object ChatModel { } } + fun getChatItemIndexOrNull(cItem: ChatItem): Int? { + val reversedChatItems = chatItems.asReversed() + val index = reversedChatItems.indexOfFirst { it.id == cItem.id } + return if (index != -1) index else null + } + + // this function analyses "connected" events and assumes that each member will be there only once + fun getConnectedMemberNames(cItem: ChatItem): Pair> { + var count = 0 + val ns = mutableListOf() + var idx = getChatItemIndexOrNull(cItem) + if (cItem.mergeCategory != null && idx != null) { + val reversedChatItems = chatItems.asReversed() + while (idx < reversedChatItems.size) { + val ci = reversedChatItems[idx] + if (ci.mergeCategory != cItem.mergeCategory) break + val m = ci.memberConnected + if (m != null) { + ns.add(m.displayName) + } + count++ + idx++ + } + } + return count to ns + } + + // returns the index of the passed item and the next item (it has smaller index) + fun getNextChatItem(ci: ChatItem): Pair { + val i = getChatItemIndexOrNull(ci) + return if (i != null) { + val reversedChatItems = chatItems.asReversed() + i to if (i > 0) reversedChatItems[i - 1] else null + } else { + null to null + } + } + + // returns the index of the first item in the same merged group (the first hidden item) + // and the previous visible item with another merge category + fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair { + var i = ciIndex ?: return null to null + val reversedChatItems = chatItems.asReversed() + val fst = reversedChatItems.lastIndex + while (i < fst) { + i++ + val ci = reversedChatItems[i] + if (ciCategory == null || ciCategory != ci.mergeCategory) { + return i - 1 to ci + } + } + return i to null + } + + // returns the previous member in the same merge group and the count of members in this group + fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair { + val reversedChatItems = chatItems.asReversed() + var prevMember: GroupMember? = null + val names: MutableSet = mutableSetOf() + for (i in range) { + val dir = reversedChatItems[i].chatDir + if (dir is CIDirection.GroupRcv) { + val m = dir.groupMember + if (prevMember == null && m.groupMemberId != member.groupMemberId) { + prevMember = m + } + names.add(m.groupMemberId) + } + } + return prevMember to names.size + } + // func popChat(_ id: String) { // if let i = getChatIndex(id) { // popChat_(i) @@ -474,7 +547,7 @@ object ChatModel { } // update current chat return if (chatId.value == groupInfo.id) { - val memberIndex = groupMembers.indexOfFirst { it.id == member.id } + val memberIndex = groupMembers.indexOfFirst { it.groupMemberId == member.groupMemberId } if (memberIndex >= 0) { groupMembers[memberIndex] = member false @@ -1090,11 +1163,11 @@ data class GroupMember ( val groupMemberId: Long, val groupId: Long, val memberId: String, - var memberRole: GroupMemberRole, - var memberCategory: GroupMemberCategory, - var memberStatus: GroupMemberStatus, - var memberSettings: GroupMemberSettings, - var invitedBy: InvitedBy, + val memberRole: GroupMemberRole, + val memberCategory: GroupMemberCategory, + val memberStatus: GroupMemberStatus, + val memberSettings: GroupMemberSettings, + val invitedBy: InvitedBy, val localDisplayName: String, val memberProfile: LocalProfile, val memberContactId: Long? = null, @@ -1467,7 +1540,7 @@ data class ChatItem ( chatController.appPrefs.privacyEncryptLocalFiles.get() val memberDisplayName: String? get() = - if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName + if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.chatViewName else null val isDeletedContent: Boolean get() = @@ -1491,6 +1564,29 @@ data class ChatItem ( else -> null } + val mergeCategory: CIMergeCategory? + get() = when (content) { + is CIContent.RcvChatFeature, + is CIContent.SndChatFeature, + is CIContent.RcvGroupFeature, + is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature + is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { + is RcvGroupEvent.UserRole, is RcvGroupEvent.UserDeleted, is RcvGroupEvent.GroupDeleted, is RcvGroupEvent.MemberCreatedContact -> null + else -> CIMergeCategory.RcvGroupEvent + } + is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { + is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft -> null + else -> CIMergeCategory.SndGroupEvent + } + else -> { + if (meta.itemDeleted == null) { + null + } else { + if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted + } + } + } + fun memberToModerate(chatInfo: ChatInfo): Pair? { return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { val m = chatInfo.groupInfo.membership @@ -1695,6 +1791,15 @@ data class ChatItem ( } } +enum class CIMergeCategory { + MemberConnected, + RcvGroupEvent, + SndGroupEvent, + SndItemDeleted, + RcvItemDeleted, + ChatFeature, +} + @Serializable sealed class CIDirection { @Serializable @SerialName("directSnd") class DirectSnd: CIDirection() @@ -1895,7 +2000,9 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent() @Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent() + // legacy - since v4.3.0 itemDeleted field is used @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null } + // legacy - since v4.3.0 itemDeleted field is used @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7ce508f662..9a9b48a35e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -745,6 +745,9 @@ object ChatController { } } + suspend fun apiSetMemberSettings(groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = + sendCommandOkResp(CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) + suspend fun apiContactInfo(contactId: Long): Pair? { val r = sendCmd(CC.APIContactInfo(contactId)) if (r is CR.ContactInfo) return r.connectionStats to r.customUserProfile @@ -1926,6 +1929,7 @@ sealed class CC { class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() + class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC() class APIContactInfo(val contactId: Long): CC() class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class APISwitchContact(val contactId: Long): CC() @@ -2036,6 +2040,7 @@ sealed class CC { is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" + is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is APISwitchContact -> "/_switch @$contactId" @@ -2139,9 +2144,10 @@ sealed class CC { is APITestProtoServer -> "testProtoServer" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" - is APISetNetworkConfig -> "/apiSetNetworkConfig" - is APIGetNetworkConfig -> "/apiGetNetworkConfig" - is APISetChatSettings -> "/apiSetChatSettings" + is APISetNetworkConfig -> "apiSetNetworkConfig" + is APIGetNetworkConfig -> "apiGetNetworkConfig" + is APISetChatSettings -> "apiSetChatSettings" + is ApiSetMemberSettings -> "apiSetMemberSettings" is APIContactInfo -> "apiContactInfo" is APIGroupMemberInfo -> "apiGroupMemberInfo" is APISwitchContact -> "apiSwitchContact" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 53a5fd9d62..9b69ddb5a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -382,7 +382,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List): List> { return memberDeliveryStatuses.mapNotNull { mds -> - chatModel.groupMembers.firstOrNull { it.groupMemberId == mds.groupMemberId }?.let { mem -> + chatModel.getGroupMember(mds.groupMemberId)?.let { mem -> mem to mds.memberDeliveryStatus } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index ac7161044f..e724dfd7cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -152,6 +152,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: hideKeyboard(view) AudioPlayer.stop() chatModel.chatId.value = null + chatModel.groupMembers.clear() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -212,7 +213,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: setGroupMembers(groupInfo, chatModel) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> - remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close) } } @@ -263,6 +264,25 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, + deleteMessages = { itemIds -> + if (itemIds.isNotEmpty()) { + val chatInfo = chat.chatInfo + withBGApi { + val deletedItems: ArrayList = arrayListOf() + for (itemId in itemIds) { + val di = chatModel.controller.apiDeleteChatItem( + chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal + )?.deletedChatItem?.chatItem + if (di != null) { + deletedItems.add(di) + } + } + for (di in deletedItems) { + chatModel.removeChatItem(chatInfo, di) + } + } + } + }, receiveFile = { fileId, encrypted -> withApi { chatModel.controller.receiveFile(user, fileId, encrypted) } }, @@ -442,6 +462,7 @@ fun ChatLayout( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -517,7 +538,7 @@ fun ChatLayout( ) { ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, - useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, + useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, @@ -744,6 +765,7 @@ fun BoxWithConstraintsScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -846,31 +868,27 @@ fun BoxWithConstraintsScope.ChatItemsList( } } } - val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null - if (chat.chatInfo is ChatInfo.Group) { - if (cItem.chatDir is CIDirection.GroupRcv) { - val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null - val nextItem = if (i - 1 >= 0) reversedChatItems[i - 1] else null - fun getConnectedMemberNames(): List { - val ns = mutableListOf() - var idx = i - while (idx < reversedChatItems.size) { - val m = reversedChatItems[idx].memberConnected - if (m != null) { - ns.add(m.displayName) - } else { - break - } - idx++ - } - return ns - } - if (cItem.memberConnected != null && nextItem?.memberConnected != null) { - // memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView - Box(Modifier.size(0.dp)) {} - } else { + + val revealed = remember { mutableStateOf(false) } + + @Composable + fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) { + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + } + + @Composable + fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) { + val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null + if (chat.chatInfo is ChatInfo.Group) { + if (cItem.chatDir is CIDirection.GroupRcv) { val member = cItem.chatDir.groupMember - if (showMemberImage(member, prevItem)) { + val (prevMember, memCount) = + if (range != null) { + chatModel.getPrevHiddenMember(member, range) + } else { + null to 1 + } + if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) { Column( Modifier .padding(top = 8.dp) @@ -880,7 +898,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { if (cItem.content.showMemberName) { Text( - member.displayName, + memberNames(member, prevMember, memCount), Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp), style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) ) @@ -898,7 +916,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { MemberImage(member) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemViewShortHand(cItem, range) } } } else { @@ -907,28 +925,45 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemViewShortHand(cItem, range) } } + } else { + Box( + Modifier + .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) + .then(swipeableModifier) + ) { + ChatItemViewShortHand(cItem, range) + } } - } else { + } else { // direct message + val sent = cItem.chatDir.sent Box( - Modifier - .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) - .then(swipeableModifier) + Modifier.padding( + start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp, + end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, + ).then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemViewShortHand(cItem, range) } } - } else { // direct message - val sent = cItem.chatDir.sent - Box( - Modifier.padding( - start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp, - end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, - ).then(swipeableModifier) - ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + } + + val (currIndex, nextItem) = chatModel.getNextChatItem(cItem) + val ciCategory = cItem.mergeCategory + if (ciCategory != null && ciCategory == nextItem?.mergeCategory) { + // memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView + } else { + val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val range = chatViewItemsRange(currIndex, prevHidden) + if (revealed.value && range != null) { + reversedChatItems.subList(range.first, range.last + 1).forEachIndexed { index, ci -> + val prev = if (index + range.first == prevHidden) prevItem else reversedChatItems[index + range.first + 1] + ChatItemView(ci, null, prev) + } + } else { + ChatItemView(cItem, range, prevItem) } } @@ -1106,10 +1141,12 @@ fun PreloadItems( } } -fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean { - return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd || - (prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId) -} +private fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean = + when (val dir = prevItem?.chatDir) { + is CIDirection.GroupSnd -> true + is CIDirection.GroupRcv -> dir.groupMember.groupMemberId != member.groupMemberId + else -> false + } val MEMBER_IMAGE_SIZE: Dp = 38.dp @@ -1206,6 +1243,29 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha } } +@Composable +private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount: Int): String { + val name = member.displayName + val prevName = prevMember?.displayName + return if (prevName != null) { + if (memCount > 2) { + stringResource(MR.strings.group_members_n).format(name, prevName, memCount - 2) + } else { + stringResource(MR.strings.group_members_2).format(name, prevName) + } + } else { + name + } +} + +fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? = + if (currIndex != null && prevHidden != null && prevHidden > currIndex) { + currIndex..prevHidden + } else { + null + } + + sealed class ProviderMedia { data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia() data class Video(val uri: URI, val preview: String): ProviderMedia() @@ -1347,6 +1407,7 @@ fun PreviewChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, + deleteMessages = { _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -1418,6 +1479,7 @@ fun PreviewGroupChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, + deleteMessages = {}, receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index f475d045cf..8c9619703b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -4,10 +4,12 @@ import InfoRow import SectionBottomSpacer import SectionDividerSpaced import SectionItemView +import SectionItemViewLongClickable import SectionSpacer import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.material.* @@ -31,6 +33,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -82,7 +85,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR member to null } ModalManager.end.showModalCloseable(true) { closeCurrent -> - remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) { closeCurrent() close() @@ -157,6 +160,23 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U ) } +private fun removeMemberAlert(groupInfo: GroupInfo, mem: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.button_remove_member_question), + text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone), + confirmText = generalGetString(MR.strings.remove_member_confirmation), + onConfirm = { + withApi { + val updatedMember = chatModel.controller.apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + if (updatedMember != null) { + chatModel.upsertGroupMember(groupInfo, updatedMember) + } + } + }, + destructive = true, + ) +} + @Composable fun GroupChatInfoLayout( chat: Chat, @@ -238,8 +258,10 @@ fun GroupChatInfoLayout( } items(filteredMembers.value) { member -> Divider() - SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) { - MemberRow(member) + val showMenu = remember { mutableStateOf(false) } + SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp) { + DropDownMenuForMember(member, groupInfo, showMenu) + MemberRow(member, onClick = { showMemberInfo(member) }) } } item { @@ -344,7 +366,7 @@ private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick } @Composable -private fun MemberRow(member: GroupMember, user: Boolean = false) { +private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -> Unit)? = null) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -390,6 +412,29 @@ private fun MemberVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary) } +@Composable +private fun DropDownMenuForMember(member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { + DefaultDropdownMenu(showMenu) { + if (member.canBeRemoved(groupInfo)) { + ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + removeMemberAlert(groupInfo, member) + showMenu.value = false + }) + } + if (member.memberSettings.showMessages) { + ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { + blockMemberAlert(groupInfo, member) + showMenu.value = false + }) + } else { + ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { + unblockMemberAlert(groupInfo, member) + showMenu.value = false + }) + } + } +} + @Composable private fun GroupLinkButton(onClick: () -> Unit) { SettingsActionItem( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e486aca4a0..9f52f61deb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -96,6 +96,8 @@ fun GroupMemberInfoView( connectViaAddress = { connReqUri -> connectViaMemberAddressAlert(connReqUri) }, + blockMember = { blockMemberAlert(groupInfo, member) }, + unblockMember = { unblockMemberAlert(groupInfo, member) }, removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }, onRoleSelected = { if (it == newRole.value) return@GroupMemberInfoLayout @@ -162,7 +164,7 @@ fun GroupMemberInfoView( }, verifyClicked = { ModalManager.end.showModalCloseable { close -> - remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> VerifyCodeView( mem.displayName, connectionCode, @@ -224,6 +226,8 @@ fun GroupMemberInfoLayout( openDirectChat: (Long) -> Unit, createMemberContact: () -> Unit, connectViaAddress: (String) -> Unit, + blockMember: () -> Unit, + unblockMember: () -> Unit, removeMember: () -> Unit, onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, @@ -338,9 +342,14 @@ fun GroupMemberInfoLayout( } } - if (member.canBeRemoved(groupInfo)) { - SectionDividerSpaced(maxBottomPadding = false) - SectionView { + SectionDividerSpaced(maxBottomPadding = false) + SectionView { + if (member.memberSettings.showMessages) { + BlockMemberButton(blockMember) + } else { + UnblockMemberButton(unblockMember) + } + if (member.canBeRemoved(groupInfo)) { RemoveMemberButton(removeMember) } } @@ -396,6 +405,26 @@ fun GroupMemberInfoHeader(member: GroupMember) { } } +@Composable +fun BlockMemberButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_back_hand), + stringResource(MR.strings.block_member_button), + click = onClick, + textColor = Color.Red, + iconColor = Color.Red, + ) +} + +@Composable +fun UnblockMemberButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_do_not_touch), + stringResource(MR.strings.unblock_member_button), + click = onClick + ) +} + @Composable fun RemoveMemberButton(onClick: () -> Unit) { SettingsActionItem( @@ -485,6 +514,43 @@ fun connectViaMemberAddressAlert(connReqUri: String) { } } +fun blockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.block_member_question), + text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), + confirmText = generalGetString(MR.strings.block_member_confirmation), + onConfirm = { + toggleShowMemberMessages(gInfo, mem, false) + }, + destructive = true, + ) +} + +fun unblockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.unblock_member_question), + text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), + confirmText = generalGetString(MR.strings.unblock_member_confirmation), + onConfirm = { + toggleShowMemberMessages(gInfo, mem, true) + }, + ) +} + +fun toggleShowMemberMessages(gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) { + val updatedMemberSettings = member.memberSettings.copy(showMessages = showMessages) + updateMemberSettings(gInfo, member, updatedMemberSettings) +} + +fun updateMemberSettings(gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) { + withBGApi { + val success = ChatController.apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings) + if (success) { + ChatModel.upsertGroupMember(gInfo, member.copy(memberSettings = memberSettings)) + } + } +} + @Preview @Composable fun PreviewGroupMemberInfoLayout() { @@ -500,6 +566,8 @@ fun PreviewGroupMemberInfoLayout() { openDirectChat = {}, createMemberContact = {}, connectViaAddress = {}, + blockMember = {}, + unblockMember = {}, removeMember = {}, onRoleSelected = {}, switchMemberAddress = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index e919ab1aa8..63d07627ca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -1,19 +1,119 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatItem -import chat.simplex.common.model.Feature +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull +import chat.simplex.common.views.helpers.onRightClick @Composable fun CIChatFeatureView( + chatItem: ChatItem, + feature: Feature, + iconColor: Color, + icon: Painter? = null, + revealed: MutableState, + showMenu: MutableState, +) { + val merged = if (!revealed.value) mergedFeatures(chatItem) else emptyList() + Box( + Modifier + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = {} + ) + .onRightClick { showMenu.value = true } + ) { + if (!revealed.value && merged != null) { + Row( + Modifier.padding(horizontal = 6.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + merged.forEach { + FeatureIconView(it) + } + } + } else { + FullFeatureView(chatItem, feature, iconColor, icon) + } + } +} + +private data class FeatureInfo( + val icon: PainterBox, + val color: Color, + val param: String? +) + +private class PainterBox( + val featureName: String, + val icon: Painter, +) { + override fun hashCode(): Int = featureName.hashCode() + override fun equals(other: Any?): Boolean = other is PainterBox && featureName == other.featureName +} + +@Composable +private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): FeatureInfo = + FeatureInfo( + icon = PainterBox(type, iconFilled()), + color = color, + param = if (this.hasParam && param != null) timeText(param) else null + ) + +@Composable +private fun mergedFeatures(chatItem: ChatItem): List? { + val m = ChatModel + val fs: ArrayList = arrayListOf() + val icons: MutableSet = mutableSetOf() + var i = getChatItemIndexOrNull(chatItem) + if (i != null) { + val reversedChatItems = m.chatItems.asReversed() + while (i < reversedChatItems.size) { + val f = featureInfo(reversedChatItems[i]) ?: break + if (!icons.contains(f.icon)) { + fs.add(0, f) + icons.add(f.icon) + } + i++ + } + } + return if (fs.size > 1) fs else null +} + +@Composable +private fun featureInfo(ci: ChatItem): FeatureInfo? = + when (ci.content) { + is CIContent.RcvChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) + is CIContent.SndChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) + is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) + is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) + else -> null + } + +@Composable +private fun FeatureIconView(f: FeatureInfo) { + val icon = @Composable { Icon(f.icon.icon, null, Modifier.size(20.dp), tint = f.color) } + if (f.param != null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + icon() + Text(chatEventText(f.param, ""), maxLines = 1) + } + } else { + icon() + } +} + +@Composable +private fun FullFeatureView( chatItem: ChatItem, feature: Feature, iconColor: Color, @@ -24,7 +124,7 @@ fun CIChatFeatureView( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(18.dp), tint = iconColor) + Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(20.dp), tint = iconColor) Text( chatEventText(chatItem), Modifier, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt index 508116f7e3..1e20a372ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt @@ -1,11 +1,9 @@ package chat.simplex.common.views.chat.item import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.* import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.unit.dp @@ -14,12 +12,7 @@ import chat.simplex.common.ui.theme.* @Composable fun CIEventView(text: AnnotatedString) { - Row( - Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)) - } + Text(text, Modifier.padding(horizontal = 6.dp, vertical = 6.dp), style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)) } @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index dd9fe4d4a8..d2dcfcaeec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -21,9 +21,8 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.chat.ComposeContextItem -import chat.simplex.common.views.chat.ComposeState import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -47,7 +46,10 @@ fun ChatItemView( imageProvider: (() -> ImageGalleryProvider)? = null, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, + revealed: MutableState, + range: IntRange?, deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -63,14 +65,12 @@ fun ChatItemView( findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, - getConnectedMemberNames: (() -> List)? = null, developerTools: Boolean, ) { val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart val showMenu = remember { mutableStateOf(false) } - val revealed = remember { mutableStateOf(false) } val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val onLinkLongClick = { _: String -> showMenu.value = true } val live = composeState.value.liveMessage != null @@ -178,61 +178,75 @@ fun ChatItemView( fun MsgContentItemDropdownMenu() { val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) DefaultDropdownMenu(showMenu) { - if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { - MsgReactionsMenu() - } - if (cItem.meta.itemDeleted == null && !live) { - ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) - } - showMenu.value = false - }) - } - val clipboard = LocalClipboardManager.current - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val fileSource = getLoadedFileSource(cItem.file) - when { - fileSource != null -> shareFile(cItem.text, fileSource) - else -> clipboard.shareText(cItem.content.text) + if (cItem.content.msgContent != null) { + if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { + MsgReactionsMenu() } - showMenu.value = false - }) - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { - SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { - composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + if (cItem.meta.itemDeleted == null && !live) { + ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } + showMenu.value = false + }) + } + val clipboard = LocalClipboardManager.current + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + val fileSource = getLoadedFileSource(cItem.file) + when { + fileSource != null -> shareFile(cItem.text, fileSource) + else -> clipboard.shareText(cItem.content.text) + } showMenu.value = false }) - } - if (cItem.meta.itemDeleted != null && revealed.value) { - ItemAction( - stringResource(MR.strings.hide_verb), - painterResource(MR.images.ic_visibility_off), - onClick = { - revealed.value = false + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { + SaveContentItemAction(cItem, saveFileLauncher, showMenu) + } + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { + ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) showMenu.value = false - } - ) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) - } - if (!(live && cItem.meta.isLive)) { - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) - } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + }) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + if (revealed.value) { + HideItemAction(revealed, showMenu) + } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + } + if (!(live && cItem.meta.isLive)) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + } + } else if (cItem.meta.itemDeleted != null) { + if (revealed.value) { + HideItemAction(revealed, showMenu) + } else if (!cItem.isDeletedContent) { + RevealItemAction(revealed, showMenu) + } else if (range != null) { + ExpandItemAction(revealed, showMenu) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } else if (cItem.isDeletedContent) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } else if (cItem.mergeCategory != null) { + if (revealed.value) { + ShrinkItemAction(revealed, showMenu) + } else { + ExpandItemAction(revealed, showMenu) + } } } } @@ -241,25 +255,18 @@ fun ChatItemView( fun MarkedDeletedItemDropdownMenu() { DefaultDropdownMenu(showMenu) { if (!cItem.isDeletedContent) { - ItemAction( - stringResource(MR.strings.reveal_verb), - painterResource(MR.images.ic_visibility), - onClick = { - revealed.value = true - showMenu.value = false - } - ) + RevealItemAction(revealed, showMenu) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } } @Composable fun ContentItem() { val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && !revealed.value) { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL) + if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed) MarkedDeletedItemDropdownMenu() } else { if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { @@ -281,7 +288,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } } @@ -289,9 +296,32 @@ fun ChatItemView( CICallItemView(cInfo, cItem, status, duration, acceptCall) } + fun mergedGroupEventText(chatItem: ChatItem): String? { + val (count, ns) = chatModel.getConnectedMemberNames(chatItem) + val members = when { + ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) + ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) + ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) + ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) + else -> "" + } + return if (count <= 1) { + null + } else if (ns.isEmpty()) { + generalGetString(MR.strings.rcv_group_events_count).format(count) + } else if (count > ns.size) { + members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) + } else { + members + } + } + fun eventItemViewText(): AnnotatedString { val memberDisplayName = cItem.memberDisplayName - return if (memberDisplayName != null) { + val t = mergedGroupEventText(cItem) + return if (!revealed.value && t != null) { + chatEventText(t, cItem.timestampText) + } else if (memberDisplayName != null) { buildAnnotatedString { withStyle(chatEventStyle) { append(memberDisplayName) } append(" ") @@ -305,35 +335,12 @@ fun ChatItemView( CIEventView(eventItemViewText()) } - fun membersConnectedText(): String? { - return if (getConnectedMemberNames != null) { - val ns = getConnectedMemberNames() - when { - ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) - ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) - ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) - else -> null - } - } else { - null - } - } - - fun membersConnectedItemText(): AnnotatedString { - val t = membersConnectedText() - return if (t != null) { - chatEventText(t, cItem.timestampText) - } else { - eventItemViewText() - } - } - @Composable fun ModeratedItem() { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL) + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage) + DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) } } @@ -352,26 +359,61 @@ fun ChatItemView( is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) - is CIContent.RcvDirectEventContent -> EventItemView() - is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) { - is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText()) - is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - else -> EventItemView() + is CIContent.RcvDirectEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupEventContent -> { + when (c.rcvGroupEvent) { + is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.SndConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeature -> { + CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndChatFeature -> { + CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() } - is CIContent.SndGroupEventContent -> EventItemView() - is CIContent.RcvConnEventContent -> EventItemView() - is CIContent.SndConnEventContent -> EventItemView() - is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) - is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) is CIContent.RcvChatPreference -> { val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) } - is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,) - is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red) - is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red) + is CIContent.SndChatPreference -> { + CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeature -> { + CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupFeature -> { + CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeatureRejected -> { + CIChatFeatureView(cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeatureRejected -> { + CIChatFeatureView(cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } is CIContent.SndModerated -> ModeratedItem() is CIContent.RcvModerated -> ModeratedItem() is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) @@ -430,16 +472,38 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( cItem: ChatItem, + revealed: MutableState, showMenu: MutableState, questionText: String, - deleteMessage: (Long, CIDeleteMode) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, ) { ItemAction( stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), onClick = { showMenu.value = false - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + if (!revealed.value && cItem.meta.itemDeleted != null) { + val currIndex = chatModel.getChatItemIndexOrNull(cItem) + val ciCategory = cItem.mergeCategory + if (currIndex != null && ciCategory != null) { + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val range = chatViewItemsRange(currIndex, prevHidden) + if (range != null) { + val itemIds: ArrayList = arrayListOf() + for (i in range) { + itemIds.add(chatModel.chatItems.asReversed()[i].id) + } + deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages) + } else { + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + } + } else { + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + } + } else { + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + } }, color = Color.Red ) @@ -463,6 +527,54 @@ fun ModerateItemAction( ) } +@Composable +private fun RevealItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.reveal_verb), + painterResource(MR.images.ic_visibility), + onClick = { + revealed.value = true + showMenu.value = false + } + ) +} + +@Composable +private fun HideItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.hide_verb), + painterResource(MR.images.ic_visibility_off), + onClick = { + revealed.value = false + showMenu.value = false + } + ) +} + +@Composable +private fun ExpandItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.expand_verb), + painterResource(MR.images.ic_expand_all), + onClick = { + revealed.value = true + showMenu.value = false + }, + ) +} + +@Composable +private fun ShrinkItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.hide_verb), + painterResource(MR.images.ic_collapse_all), + onClick = { + revealed.value = false + showMenu.value = false + }, + ) +} + @Composable fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) { val finalColor = if (color == Color.Unspecified) { @@ -542,6 +654,26 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes ) } +fun deleteMessagesAlertDialog(itemIds: List, questionText: String, deleteMessages: (List) -> Unit) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size), + text = questionText, + buttons = { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.Center, + ) { + TextButton(onClick = { + deleteMessages(itemIds) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + } + } + ) +} + fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_member_message__question), @@ -575,7 +707,10 @@ fun PreviewChatItemView() { useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + revealed = remember { mutableStateOf(false) }, + range = 0..1, deleteMessage = { _, _ -> }, + deleteMessages = { _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -606,7 +741,10 @@ fun PreviewChatItemViewDeletedContent() { useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + revealed = remember { mutableStateOf(false) }, + range = 0..1, deleteMessage = { _, _ -> }, + deleteMessages = { _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 122e54c3b2..1d3e8bb202 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -202,10 +202,16 @@ fun FramedItemView( Column(Modifier.width(IntrinsicSize.Max)) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { if (ci.meta.itemDeleted != null) { - if (ci.meta.itemDeleted is CIDeleted.Moderated) { - FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) - } else { - FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + when (ci.meta.itemDeleted) { + is CIDeleted.Moderated -> { + FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) + } + is CIDeleted.Blocked -> { + FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) + } + else -> { + FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + } } } else if (ci.meta.isLive) { FramedItemHeader(stringResource(MR.strings.live), false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 84675a09b4..50d905ef76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -2,25 +2,25 @@ package chat.simplex.common.views.chat.item import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.CIDeleted -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { +fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState) { val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Surface( @@ -32,11 +32,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f, false)) { - if (ci.meta.itemDeleted is CIDeleted.Moderated) { - MarkedDeletedText(String.format(generalGetString(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName)) - } else { - MarkedDeletedText(generalGetString(MR.strings.marked_deleted_description)) - } + MergedMarkedDeletedText(ci, revealed) } CIMetaView(ci, timedMessagesTTL) } @@ -44,7 +40,41 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { } @Composable -private fun MarkedDeletedText(text: String) { +private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState) { + var i = getChatItemIndexOrNull(chatItem) + val ciCategory = chatItem.mergeCategory + val text = if (!revealed.value && ciCategory != null && i != null) { + val reversedChatItems = ChatModel.chatItems.asReversed() + var moderated = 0 + var blocked = 0 + var deleted = 0 + val moderatedBy: MutableSet = mutableSetOf() + while (i < reversedChatItems.size) { + val ci = reversedChatItems.getOrNull(i) + if (ci?.mergeCategory != ciCategory) break + when (val itemDeleted = ci.meta.itemDeleted ?: break) { + is CIDeleted.Moderated -> { + moderated += 1 + moderatedBy.add(itemDeleted.byGroupMember.displayName) + } + is CIDeleted.Blocked -> blocked += 1 + is CIDeleted.Deleted -> deleted += 1 + } + i++ + } + val total = moderated + blocked + deleted + if (total <= 1) + markedDeletedText(chatItem.meta) + else if (total == moderated) + stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", ")) + else if (total == blocked) + stringResource(MR.strings.blocked_items_description).format(total) + else + stringResource(MR.strings.marked_deleted_items_description).format(total) + } else { + markedDeletedText(chatItem.meta) + } + Text( buildAnnotatedString { withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) } @@ -56,6 +86,16 @@ private fun MarkedDeletedText(text: String) { ) } +private fun markedDeletedText(meta: CIMeta): String = + when (meta.itemDeleted) { + is CIDeleted.Moderated -> + String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName) + is CIDeleted.Blocked -> + generalGetString(MR.strings.blocked_item_description) + else -> + generalGetString(MR.strings.marked_deleted_description) + } + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 6adbfed76c..c6ae0d6d48 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -98,6 +98,34 @@ fun SectionItemView( } } +@Composable +fun SectionItemViewLongClickable( + click: () -> Unit, + longClick: () -> Unit, + minHeight: Dp = 46.dp, + disabled: Boolean = false, + extraPadding: Boolean = false, + padding: PaddingValues = if (extraPadding) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING) + else + PaddingValues(horizontal = DEFAULT_PADDING), + content: (@Composable RowScope.() -> Unit) +) { + val modifier = Modifier + .fillMaxWidth() + .sizeIn(minHeight = minHeight) + Row( + if (disabled) { + modifier.padding(padding) + } else { + modifier.combinedClickable(onClick = click, onLongClick = longClick).onRightClick(longClick).padding(padding) + }, + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + @Composable fun SectionItemViewWithIcon( click: (() -> Unit)? = null, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index aa76a768e2..9df3f9d621 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -30,7 +30,11 @@ deleted marked deleted + %d messages marked deleted moderated by %s + %d messages moderated by %s + blocked + %d messages blocked sending files is not supported yet receiving files is not supported yet you @@ -243,7 +247,9 @@ Hide Allow Moderate + Expand Delete message? + Delete %d messages? Message will be deleted - this cannot be undone! Message will be marked for deletion. The recipient(s) will be able to reveal this message. Delete member message? @@ -1132,9 +1138,14 @@ you left group profile updated + %s connected %s and %s connected %s, %s and %s connected %s, %s and %d other members connected + %d group events + and %d other events + %s and %s + %s, %s and %d members Open @@ -1250,10 +1261,21 @@ %s: %s + Remove member? Remove member + Send direct message Member will be removed from group - this cannot be undone! Remove + Remove member + Block member? + Block member + Block + All new messages from %s will be hidden! + Unblock member? + Unblock member + Unblock + Messages from %s will be shown! MEMBER Role Change role diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg new file mode 100644 index 0000000000..41013ff66c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg new file mode 100644 index 0000000000..a380594e79 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg new file mode 100644 index 0000000000..5ea1a5f2e3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg new file mode 100644 index 0000000000..75b2874d5b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg @@ -0,0 +1 @@ + \ No newline at end of file From 4fd38a270c5777450705b5810b056eb369d013a0 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 2 Nov 2023 02:23:41 +0800 Subject: [PATCH 136/294] desktop: adding build version code to UI (#3304) --- apps/multiplatform/common/build.gradle.kts | 1 + .../commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt | 2 +- .../chat/simplex/common/views/usersettings/VersionInfoView.kt | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 13ca2c309d..7100973165 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -138,6 +138,7 @@ buildConfig { buildConfigField("String", "ANDROID_VERSION_NAME", "\"${extra["android.version_name"]}\"") buildConfigField("int", "ANDROID_VERSION_CODE", "${extra["android.version_code"]}") buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") + buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index d36a6aec16..b10a30233f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -21,7 +21,7 @@ expect val appPlatform: AppPlatform val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE else - BuildConfigCommon.DESKTOP_VERSION_NAME to null + BuildConfigCommon.DESKTOP_VERSION_NAME to BuildConfigCommon.DESKTOP_VERSION_CODE class FifoQueue(private var capacity: Int) : LinkedList() { override fun add(element: E): Boolean { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index 8528414771..010b94e034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -24,6 +24,7 @@ fun VersionInfoView(info: CoreVersionInfo) { Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE)) } else { Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.DESKTOP_VERSION_NAME)) + Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.DESKTOP_VERSION_CODE)) } Text(String.format(stringResource(MR.strings.core_version), info.version)) val simplexmqCommit = if (info.simplexmqCommit.length >= 7) info.simplexmqCommit.substring(startIndex = 0, endIndex = 7) else info.simplexmqCommit From 8482dbfd99810edc98cae6e0e13c3b613ff9206d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 1 Nov 2023 19:08:36 +0000 Subject: [PATCH 137/294] core: update remote API commands/events (#3295) * core: update remote API * Add session verification event between tls and http2 * roll back char_ '@' parsers * use more specific parser for verification codes * cabal.project.local for mac --------- Co-authored-by: IC Rainbow --- cabal.project | 2 +- scripts/cabal.project.local.mac | 5 ++ src/Simplex/Chat.hs | 28 ++++---- src/Simplex/Chat/Controller.hs | 41 ++++++------ src/Simplex/Chat/Remote.hs | 100 +++++++++++++++++------------ src/Simplex/Chat/Remote/RevHTTP.hs | 5 -- src/Simplex/Chat/View.hs | 19 ++++-- tests/RemoteTests.hs | 21 ++++-- 8 files changed, 130 insertions(+), 91 deletions(-) diff --git a/cabal.project b/cabal.project index b693f6c88f..7fe664f4e1 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: db1b2f77cd1c172fab26b68c507cdd2c1b7b0e63 + tag: a5fed340e2814a226180ce1abe606ac79366fe5b source-repository-package type: git diff --git a/scripts/cabal.project.local.mac b/scripts/cabal.project.local.mac index 35c10db756..dd62f1a391 100644 --- a/scripts/cabal.project.local.mac +++ b/scripts/cabal.project.local.mac @@ -1,6 +1,11 @@ ignore-project: False -- amend to point to the actual openssl location + +package simplexmq + extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include + extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib + package direct-sqlcipher extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 157d7cea26..28b4c51120 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1922,10 +1922,10 @@ processChatCommand = \case DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ StoreRemoteFile rh encrypted_ localPath -> CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath GetRemoteFile rh rf -> getRemoteFile rh rf >> ok_ - StartRemoteCtrl -> withUser_ $ startRemoteCtrl (execChatCommand Nothing) >> ok_ - RegisterRemoteCtrl oob -> withUser_ $ CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) - AcceptRemoteCtrl rc -> withUser_ $ acceptRemoteCtrl rc >> ok_ - RejectRemoteCtrl rc -> withUser_ $ rejectRemoteCtrl rc >> ok_ + ConnectRemoteCtrl oob -> withUser_ $ CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob) + FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl (execChatCommand Nothing) >> ok_ + ConfirmRemoteCtrl rc -> withUser_ $ confirmRemoteCtrl rc >> ok_ + VerifyRemoteCtrlSession rc sessId -> withUser_ $ verifyRemoteCtrlSession rc sessId >> ok_ StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ @@ -5798,14 +5798,14 @@ chatCommandP = "/sync " *> char_ '@' *> (SyncContactRatchet <$> displayName <*> (" force=on" $> True <|> pure False)), "/_get code @" *> (APIGetContactCode <$> A.decimal), "/_get code #" *> (APIGetGroupMemberCode <$> A.decimal <* A.space <*> A.decimal), - "/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> textP)), - "/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> textP)), + "/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> verifyCodeP)), + "/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> verifyCodeP)), "/_enable @" *> (APIEnableContact <$> A.decimal), "/_enable #" *> (APIEnableGroupMember <$> A.decimal <* A.space <*> A.decimal), "/code " *> char_ '@' *> (GetContactCode <$> displayName), "/code #" *> (GetGroupMemberCode <$> displayName <* A.space <* char_ '@' <*> displayName), - "/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> textP)), - "/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> textP)), + "/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> verifyCodeP)), + "/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> verifyCodeP)), "/enable " *> char_ '@' *> (EnableContact <$> displayName), "/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles, @@ -5856,7 +5856,7 @@ chatCommandP = "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), "/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP), "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), - ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), + ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), ("/connect" <|> "/c") *> (AddContact <$> incognitoP), SendMessage <$> chatNameP <* A.space <*> msgTextP, "@#" *> (SendMemberContactMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> msgTextP), @@ -5926,12 +5926,11 @@ chatCommandP = "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), "/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath), "/get remote file " *> (GetRemoteFile <$> A.decimal <* A.space <*> jsonP), - "/start remote ctrl" $> StartRemoteCtrl, - "/register remote ctrl " *> (RegisterRemoteCtrl <$> strP), - -- "/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP), + "/connect remote ctrl " *> (ConnectRemoteCtrl <$> strP), + "/find remote ctrl" $> FindKnownRemoteCtrl, + "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), + "/verify remote ctrl " *> (VerifyRemoteCtrlSession <$> A.decimal <* A.space <*> textP), "/list remote ctrls" $> ListRemoteCtrls, - "/accept remote ctrl " *> (AcceptRemoteCtrl <$> A.decimal), - "/reject remote ctrl " *> (RejectRemoteCtrl <$> A.decimal), "/stop remote ctrl" $> StopRemoteCtrl, "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, @@ -5997,6 +5996,7 @@ chatCommandP = fullNameP = A.space *> textP <|> pure "" textP = safeDecodeUtf8 <$> A.takeByteString pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' ')) + verifyCodeP = safeDecodeUtf8 <$> A.takeWhile (\c -> isDigit c || c == ' ') msgTextP = jsonP <|> textP stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString filePath = stringP diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 25fe9294c6..77976da05c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -426,11 +426,11 @@ data ChatCommand | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} - | StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers - | RegisterRemoteCtrl SignedOOB -- ^ Register OOB data for remote controller discovery and handshake + | ConnectRemoteCtrl SignedOOB -- ^ Connect new or existing controller via OOB data + | FindKnownRemoteCtrl -- ^ Start listening for announcements from all existing controllers + | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm the connection with found controller + | VerifyRemoteCtrlSession RemoteCtrlId Text -- ^ Verify remote controller session | ListRemoteCtrls - | AcceptRemoteCtrl RemoteCtrlId -- ^ Accept discovered data and store confirmation - | RejectRemoteCtrl RemoteCtrlId -- ^ Reject and blacklist discovered data | StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a remote controller session | QuitChat @@ -458,11 +458,11 @@ allowRemoteCommand = \case GetRemoteFile {} -> False StopRemoteHost _ -> False DeleteRemoteHost _ -> False - RegisterRemoteCtrl {} -> False - StartRemoteCtrl -> False + ConnectRemoteCtrl {} -> False + FindKnownRemoteCtrl -> False + ConfirmRemoteCtrl _ -> False + VerifyRemoteCtrlSession {} -> False ListRemoteCtrls -> False - AcceptRemoteCtrl _ -> False - RejectRemoteCtrl _ -> False StopRemoteCtrl -> False DeleteRemoteCtrl _ -> False ExecChatStoreSQL _ -> False @@ -641,14 +641,16 @@ data ChatResponse | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRRemoteHostStarted {remoteHost :: RemoteHostInfo, sessionOOB :: Text} + | CRRemoteHostSessionCode {remoteHost :: RemoteHostInfo, sessionCode :: Text} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} - | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation + | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} -- TODO remove + | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- TODO remove, unregistered fingerprint, needs confirmation -- TODO is it needed? | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect - | CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo} + | CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo} -- TODO is remove + | CRRemoteCtrlSessionCode {remoteCtrl :: RemoteCtrlInfo, sessionCode :: Text, newCtrl :: Bool} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped | CRSQLResult {rows :: [Text]} @@ -679,8 +681,9 @@ allowRemoteEvent = \case CRRemoteCtrlAnnounce {} -> False CRRemoteCtrlFound {} -> False CRRemoteCtrlConnecting {} -> False + CRRemoteCtrlSessionCode {} -> False CRRemoteCtrlConnected {} -> False - CRRemoteCtrlStopped {} -> False + CRRemoteCtrlStopped -> False _ -> True logResponseToFile :: ChatResponse -> Bool @@ -1060,6 +1063,7 @@ data RemoteCtrlError | RCECertificateExpired {remoteCtrlId :: RemoteCtrlId} -- ^ A connection or CA certificate in a chain have bad validity period | RCECertificateUntrusted {remoteCtrlId :: RemoteCtrlId} -- ^ TLS is unable to validate certificate chain presented for a connection | RCEBadFingerprint -- ^ Bad fingerprint data provided in OOB + | RCEBadVerificationCode -- ^ The code submitted doesn't match session TLSunique | RCEHTTP2Error {http2Error :: String} | RCEHTTP2RespStatus {statusCode :: Maybe Int} -- TODO remove | RCEInvalidResponse {responseError :: String} @@ -1071,13 +1075,14 @@ data ArchiveError | AEImportFile {file :: String, chatError :: ChatError} deriving (Show, Exception) +-- | Host (mobile) side of transport to process remote commands and forward notifications data RemoteCtrlSession = RemoteCtrlSession - { -- | Host (mobile) side of transport to process remote commands and forward notifications - discoverer :: Async (), - supervisor :: Async (), - hostServer :: Maybe (Async ()), - discovered :: TMap C.KeyHash (TransportHost, Word16), - accepted :: TMVar RemoteCtrlId, + { discoverer :: Async (), -- multicast listener + supervisor :: Async (), -- session state/subprocess supervisor + hostServer :: Maybe (Async ()), -- a running session + discovered :: TMap C.KeyHash (TransportHost, Word16), -- multicast-announced services + confirmed :: TMVar RemoteCtrlId, -- connection fingerprint found/stored in DB + verified :: TMVar (RemoteCtrlId, Text), -- user confirmed the session remoteOutputQ :: TBQueue ChatResponse } diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cc3eb9f199..cb943ac2c5 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -7,7 +8,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} - {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Remote where @@ -41,7 +41,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Messages (chatNameStr) import Simplex.Chat.Remote.Protocol -import Simplex.Chat.Remote.RevHTTP (announceRevHTTP2, connectRevHTTP2) +import Simplex.Chat.Remote.RevHTTP (announceRevHTTP2, attachHTTP2Server) import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Files @@ -56,6 +56,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding (smpDecode) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Transport (tlsUniq) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.File (hSendFile) @@ -65,6 +66,7 @@ import qualified Simplex.RemoteControl.Discovery as Discovery import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO +import UnliftIO.Concurrent (threadDelay) import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) -- * Desktop side @@ -93,13 +95,15 @@ startRemoteHost rhId = do rh <- withStore (`getRemoteHost` rhId) tasks <- startRemoteHostSession rh logInfo $ "Remote host session starting for " <> tshow rhId - asyncRegistered tasks $ run rh tasks `catchAny` \err -> do - logError $ "Remote host session startup failed for " <> tshow rhId <> ": " <> tshow err - cancelTasks tasks - chatModifyVar remoteHostSessions $ M.delete rhId - throwError $ fromMaybe (mkChatError err) $ fromException err - -- logInfo $ "Remote host session starting for " <> tshow rhId + asyncRegistered tasks $ + run rh tasks `catchAny` \err -> do + logError $ "Remote host session startup failed for " <> tshow rhId <> ": " <> tshow err + cancelTasks tasks + chatModifyVar remoteHostSessions $ M.delete rhId + throwError $ fromMaybe (mkChatError err) $ fromException err where + -- logInfo $ "Remote host session starting for " <> tshow rhId + run :: ChatMonad m => RemoteHost -> Tasks -> m () run rh@RemoteHost {storePath} tasks = do (fingerprint, credentials) <- liftIO $ genSessionCredentials rh @@ -109,7 +113,7 @@ startRemoteHost rhId = do chatModifyVar currentRemoteHost $ \cur -> if cur == Just rhId then Nothing else cur -- only wipe the closing RH withRemoteHostSession rhId $ \sessions _ -> Right <$> TM.delete rhId sessions toView (CRRemoteHostStopped rhId) -- only signal "stopped" when the session is unregistered cleanly - -- block until some client is connected or an error happens + -- block until some client is connected or an error happens logInfo $ "Remote host session connecting for " <> tshow rhId rcName <- chatReadVar localDeviceName localAddr <- asks multicastSubscribers >>= Discovery.getLocalAddress >>= maybe (throwError . ChatError $ CEInternalError "unable to get local address") pure @@ -127,12 +131,14 @@ startRemoteHost rhId = do logInfo $ "Remote host session started for " <> tshow rhId chatModifyVar remoteHostSessions $ M.adjust (\rhs -> rhs {remoteHostClient = Just remoteHostClient}) rhId chatWriteVar currentRemoteHost $ Just rhId - toView $ CRRemoteHostConnected RemoteHostInfo - { remoteHostId = rhId, - storePath = storePath, - displayName = hostDeviceName remoteHostClient, - sessionActive = True - } + toView $ + CRRemoteHostConnected + RemoteHostInfo + { remoteHostId = rhId, + storePath = storePath, + displayName = hostDeviceName remoteHostClient, + sessionActive = True + } genSessionCredentials RemoteHost {caKey, caCert} = do sessionCreds <- genCredentials (Just parent) (0, 24) "Session" @@ -251,34 +257,48 @@ liftRH rhId = liftError (ChatErrorRemoteHost rhId . RHProtocolError) -- * Mobile side -startRemoteCtrl :: forall m . ChatMonad m => (ByteString -> m ChatResponse) -> m () -startRemoteCtrl execChatCommand = do +findKnownRemoteCtrl :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> m () +findKnownRemoteCtrl execChatCommand = do logInfo "Starting remote host" checkNoRemoteCtrlSession -- tiny race with the final @chatWriteVar@ until the setup finishes and supervisor spawned discovered <- newTVarIO mempty discoverer <- async $ discoverRemoteCtrls discovered -- TODO extract to a controller service singleton size <- asks $ tbqSize . config remoteOutputQ <- newTBQueueIO size - accepted <- newEmptyTMVarIO - supervisor <- async $ runHost discovered accepted $ handleRemoteCommand execChatCommand remoteOutputQ - chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ} + confirmed <- newEmptyTMVarIO + verified <- newEmptyTMVarIO + supervisor <- async $ do + threadDelay 500000 -- give chat controller a chance to reply with "ok" to prevent flaking tests + runHost discovered confirmed verified $ handleRemoteCommand execChatCommand remoteOutputQ + chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, confirmed, verified, remoteOutputQ} -- | Track remote host lifecycle in controller session state and signal UI on its progress -runHost :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> TMVar RemoteCtrlId -> (HTTP2Request -> m ()) -> m () -runHost discovered accepted handleHttp = do - remoteCtrlId <- atomically (readTMVar accepted) -- wait for ??? +runHost :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> TMVar RemoteCtrlId -> TMVar (RemoteCtrlId, Text) -> (HTTP2Request -> m ()) -> m () +runHost discovered confirmed verified handleHttp = do + remoteCtrlId <- atomically (readTMVar confirmed) -- wait for discoverRemoteCtrls.process or confirmRemoteCtrl to confirm fingerprint as a known RC rc@RemoteCtrl {fingerprint} <- withStore (`getRemoteCtrl` remoteCtrlId) serviceAddress <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure -- wait for location of the matching fingerprint toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False atomically $ writeTVar discovered mempty -- flush unused sources - server <- async $ connectRevHTTP2 serviceAddress fingerprint handleHttp -- spawn server for remote protocol commands + server <- async $ + -- spawn server for remote protocol commands + Discovery.connectTLSClient serviceAddress fingerprint $ \tls -> do + let sessionCode = decodeUtf8 . strEncode $ tlsUniq tls + toView $ CRRemoteCtrlSessionCode {remoteCtrl = remoteCtrlInfo rc True, sessionCode, newCtrl = False} + userInfo <- atomically $ readTMVar verified + if userInfo == (remoteCtrlId, sessionCode) + then do + toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True + attachHTTP2Server handleHttp tls + else do + toView $ CRChatCmdError Nothing $ ChatErrorRemoteCtrl RCEBadVerificationCode + -- the server doesn't enter its loop and waitCatch below falls through chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server} - toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True _ <- waitCatch server -- wait for the server to finish chatWriteVar remoteCtrlSession Nothing toView CRRemoteCtrlStopped -handleRemoteCommand :: forall m . ChatMonad m => (ByteString -> m ChatResponse) -> TBQueue ChatResponse -> HTTP2Request -> m () +handleRemoteCommand :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> TBQueue ChatResponse -> HTTP2Request -> m () handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" liftRC (tryRemoteError parseRequest) >>= \case @@ -358,7 +378,7 @@ handleStoreFile fileName fileSize fileDigest getChunk = pure filePath handleGetFile :: ChatMonad m => User -> RemoteFile -> Respond m -> m () -handleGetFile User {userId} RemoteFile{userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do +handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do logDebug $ "GetFile: " <> tshow filePath unless (userId == commandUserId) $ throwChatError $ CEDifferentActiveUser {commandUserId, activeUserId = userId} path <- maybe filePath ( filePath) <$> chatReadVar filesFolder @@ -385,7 +405,7 @@ discoverRemoteCtrls discovered = do Left _ -> receive sock -- TODO it is probably better to report errors to view here _nonV4 -> receive sock - process sock (sockAddr, Announce {caFingerprint, serviceAddress=(annAddr, port)}) = do + process sock (sockAddr, Announce {caFingerprint, serviceAddress = (annAddr, port)}) = do unless (annAddr == sockAddr) $ logError "Announced address doesn't match socket address" let addr = THIPv4 (hostAddressToTuple sockAddr) ifM @@ -406,13 +426,13 @@ discoverRemoteCtrls discovered = do Just True -> chatReadVar remoteCtrlSession >>= \case Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session" - Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically + Just RemoteCtrlSession {confirmed} -> atomically $ void $ tryPutTMVar confirmed remoteCtrlId -- previously accepted controller, connect automatically listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do active <- - chatReadVar remoteCtrlSession - $>>= \RemoteCtrlSession {accepted} -> atomically $ tryReadTMVar accepted + chatReadVar remoteCtrlSession $>>= \RemoteCtrlSession {confirmed} -> + atomically $ tryReadTMVar confirmed map (rcInfo active) <$> withStore' getRemoteCtrls where rcInfo activeRcId rc@RemoteCtrl {remoteCtrlId} = @@ -422,19 +442,17 @@ remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo remoteCtrlInfo RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} sessionActive = RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted, sessionActive} -acceptRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () -acceptRemoteCtrl rcId = do +confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () +confirmRemoteCtrl rcId = do -- TODO check it exists, check the ID is the same as in session - RemoteCtrlSession {accepted} <- getRemoteCtrlSession + RemoteCtrlSession {confirmed} <- getRemoteCtrlSession withStore' $ \db -> markRemoteCtrlResolution db rcId True - atomically . void $ tryPutTMVar accepted rcId -- the remote host can now proceed with connection + atomically . void $ tryPutTMVar confirmed rcId -- the remote host can now proceed with connection -rejectRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () -rejectRemoteCtrl rcId = do - withStore' $ \db -> markRemoteCtrlResolution db rcId False - RemoteCtrlSession {discoverer, supervisor} <- getRemoteCtrlSession - cancel discoverer - cancel supervisor +verifyRemoteCtrlSession :: ChatMonad m => RemoteCtrlId -> Text -> m () +verifyRemoteCtrlSession rcId sessId = do + RemoteCtrlSession {verified} <- getRemoteCtrlSession + void . atomically $ tryPutTMVar verified (rcId, sessId) stopRemoteCtrl :: ChatMonad m => m () stopRemoteCtrl = do diff --git a/src/Simplex/Chat/Remote/RevHTTP.hs b/src/Simplex/Chat/Remote/RevHTTP.hs index c6c777596a..08c844dcf0 100644 --- a/src/Simplex/Chat/Remote/RevHTTP.hs +++ b/src/Simplex/Chat/Remote/RevHTTP.hs @@ -11,11 +11,9 @@ module Simplex.Chat.Remote.RevHTTP where import Simplex.RemoteControl.Discovery import Simplex.RemoteControl.Types import Control.Logger.Simple -import Data.Word (Word16) import qualified Network.TLS as TLS import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Transport as Transport -import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) @@ -39,9 +37,6 @@ runHTTP2Client finishedVar clientVar tls = -- TODO connection timeout config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} -connectRevHTTP2 :: (MonadUnliftIO m) => (TransportHost, Word16) -> C.KeyHash -> (HTTP2Request -> m ()) -> m () -connectRevHTTP2 serviceAddress fingerprint = connectTLSClient serviceAddress fingerprint . attachHTTP2Server - attachHTTP2Server :: (MonadUnliftIO m) => (HTTP2Request -> m ()) -> Transport.TLS -> m () attachHTTP2Server processRequest tls = do withRunInIO $ \unlift -> diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ed4b768cd8..8779480a5c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -275,17 +275,26 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRRemoteHostCreated RemoteHostInfo {remoteHostId} -> ["remote host " <> sShow remoteHostId <> " created"] CRRemoteHostList hs -> viewRemoteHosts hs CRRemoteHostStarted {remoteHost = RemoteHostInfo {remoteHostId = rhId}, sessionOOB} -> ["remote host " <> sShow rhId <> " started", "connection code:", plain sessionOOB] + CRRemoteHostSessionCode {remoteHost = RemoteHostInfo {remoteHostId = rhId}, sessionCode} -> + ["remote host " <> sShow rhId <> " is connecting", "Compare session code with host:", plain sessionCode] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> ["remote controller " <> sShow rcId <> " registered"] - CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint] - CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] - CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName] + CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> + ["remote controller " <> sShow rcId <> " registered"] + CRRemoteCtrlAnnounce fingerprint -> + ["remote controller announced", "connection code:", plain $ strEncode fingerprint] + CRRemoteCtrlFound rc -> + ["remote controller found:", viewRemoteCtrl rc] + CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> + ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName] + CRRemoteCtrlSessionCode {remoteCtrl = RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName}, sessionCode} -> + ["remote controller " <> sShow rcId <> " connected to " <> plain rcName, "Compare session code with controller and use:", "/verify remote ctrl " <> sShow rcId <> " " <> plain sessionCode] + CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> + ["remote controller " <> sShow rcId <> " session started with " <> plain rcName] CRRemoteCtrlStopped -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 7c62333d66..b2e7aa5cb1 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -30,7 +30,7 @@ import Simplex.Chat.Remote.Types import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding (smpDecode) -import Simplex.Messaging.Encoding.String (strDecode, strEncode) +import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest) @@ -408,7 +408,7 @@ startRemote mobile desktop = do mobile ##> "/set device name Mobile" mobile <## "ok" - mobile ##> "/start remote ctrl" + mobile ##> "/find remote ctrl" mobile <## "ok" mobile <## "remote controller announced" mobile <## "connection code:" @@ -416,13 +416,20 @@ startRemote mobile desktop = do -- The user scans OOB QR code and confirms it matches the announced stuff fromString annFingerprint `shouldBe` strEncode oobFingerprint - mobile ##> ("/register remote ctrl " <> oobLink) + mobile ##> ("/connect remote ctrl " <> oobLink) mobile <## "remote controller 1 registered" - mobile ##> "/accept remote ctrl 1" - mobile <## "ok" -- alternative scenario: accepted before controller start + mobile ##> "/confirm remote ctrl 1" + mobile <## "ok" mobile <## "remote controller 1 connecting to My desktop" - mobile <## "remote controller 1 connected, My desktop" - desktop <## "remote host 1 connected" + -- TODO: rework tls connection prelude + mobile <## "remote controller 1 connected to My desktop" + mobile <## "Compare session code with controller and use:" + verifyCmd <- getTermLine mobile + mobile ##> verifyCmd + mobile <## "ok" + concurrently_ + (mobile <## "remote controller 1 session started with My desktop") + (desktop <## "remote host 1 connected") contactBob :: TestCC -> TestCC -> IO () contactBob desktop bob = do From fad5128a832fe70684993c30dbbc50f1a3e853c4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 2 Nov 2023 03:11:04 +0800 Subject: [PATCH 138/294] android, desktop: updated Compose and changed mac notarization tool (#3303) * android, desktop: updated Compose and changed mac notarization tool * imports * desktop (mac): fix lib building * imports --------- Co-authored-by: Avently Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/multiplatform/android/build.gradle.kts | 4 +- apps/multiplatform/build.gradle.kts | 2 +- apps/multiplatform/common/build.gradle.kts | 2 +- .../common/platform/Modifier.android.kt | 2 + .../chatlist/ChatListNavLinkView.android.kt | 1 + .../helpers/DefaultDropDownMenu.android.kt | 48 ------------------- .../chat/simplex/common/platform/Modifier.kt | 2 + .../common/views/chat/ChatItemInfoView.kt | 1 + .../views/chat/item/CIChatFeatureView.kt | 2 +- .../common/views/chat/item/FramedItemView.kt | 3 +- .../views/chat/item/ImageFullScreenView.kt | 9 +++- .../views/helpers/DefaultDropdownMenu.kt | 24 +--------- .../helpers/ExposedDropDownSettingRow.kt | 2 +- .../simplex/common/views/helpers/Section.kt | 1 + .../usersettings/AdvancedNetworkSettings.kt | 4 +- .../common/platform/Modifier.desktop.kt | 3 ++ .../views/chat/item/CIVideoView.desktop.kt | 4 +- .../chatlist/ChatListNavLinkView.desktop.kt | 1 + .../helpers/DefaultDropDownMenu.desktop.kt | 44 ----------------- apps/multiplatform/desktop/build.gradle.kts | 2 +- apps/multiplatform/gradle.properties | 2 +- scripts/desktop/build-lib-mac.sh | 5 ++ 22 files changed, 37 insertions(+), 131 deletions(-) delete mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 873f33b22f..a35d3f5195 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } android { - compileSdkVersion(33) + compileSdkVersion(34) defaultConfig { applicationId = "chat.simplex.app" @@ -144,7 +144,7 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") //androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version") - debugImplementation("androidx.compose.ui:ui-tooling:${rootProject.extra["compose.version"] as String}") + debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") } tasks { diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index 3a6fbcbf94..9d6fd0c20b 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -36,7 +36,7 @@ buildscript { extra.set("desktop.mac.signing.keychain", prop["desktop.mac.signing.keychain"] ?: extra.getOrNull("compose.desktop.mac.signing.keychain")) extra.set("desktop.mac.notarization.apple_id", prop["desktop.mac.notarization.apple_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.appleID")) extra.set("desktop.mac.notarization.password", prop["desktop.mac.notarization.password"] ?: extra.getOrNull("compose.desktop.mac.notarization.password")) - extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.ascProvider")) + extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.teamID")) repositories { google() diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 7100973165..55e03f6209 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -107,7 +107,7 @@ kotlin { } android { - compileSdkVersion(33) + compileSdkVersion(34) sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { minSdkVersion(26) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index 41349654be..115027c1a0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -23,3 +23,5 @@ actual fun Modifier.desktopOnExternalDrag( onImage: (Painter) -> Unit, onText: (String) -> Unit ): Modifier = this + +actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt index 3f33913e54..30f5b81387 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.helpers.* @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt deleted file mode 100644 index 38dd78dd99..0000000000 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt +++ /dev/null @@ -1,48 +0,0 @@ -package chat.simplex.common.views.helpers - -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.PopupProperties - -actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this - -actual interface DefaultExposedDropdownMenuBoxScope { - @Composable - actual fun DefaultExposedDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier, - content: @Composable ColumnScope.() -> Unit - ) { - DropdownMenu(expanded, onDismissRequest, modifier, content = content) - } - - @Composable - fun DropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(0.dp, 0.dp), - properties: PopupProperties = PopupProperties(focusable = true), - content: @Composable ColumnScope.() -> Unit - ) { - androidx.compose.material.DropdownMenu(expanded, onDismissRequest, modifier, offset, properties, content) - } -} - -@Composable -actual fun DefaultExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier, - content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit -) { - val scope = remember { object : DefaultExposedDropdownMenuBoxScope {} } - androidx.compose.material.ExposedDropdownMenuBox(expanded, onExpandedChange, modifier, content = { - scope.content() - }) -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index 543444d0eb..a143057a32 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -20,3 +20,5 @@ expect fun Modifier.desktopOnExternalDrag( onImage: (Painter) -> Unit = {}, onText: (String) -> Unit = {} ): Modifier + +expect fun Modifier.onRightClick(action: () -> Unit): Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 9b69ddb5a0..63cd25092e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 63d07627ca..a9a4963c96 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull -import chat.simplex.common.views.helpers.onRightClick +import chat.simplex.common.platform.onRightClick @Composable fun CIChatFeatureView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 1d3e8bb202..c391200c2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -20,10 +20,9 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* -import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.platform.base64ToBitmap import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE import chat.simplex.res.MR import kotlin.math.min diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 4d4d847cc7..c7268592bf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -32,7 +32,12 @@ interface ImageGalleryProvider { @Composable fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) { val provider = remember { imageProvider() } - val pagerState = rememberPagerState(provider.initialIndex) + val pagerState = rememberPagerState( + initialPage = provider.initialIndex, + initialPageOffsetFraction = 0f + ) { + provider.totalMediaSize.value + } val goBack = { provider.onDismiss(pagerState.currentPage); close() } BackHandler(onBack = goBack) // Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank, @@ -138,7 +143,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } } if (appPlatform.isAndroid) { - HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Content(index) } + HorizontalPager(state = pagerState) { index -> Content(index) } } else { Content(pagerState.currentPage) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt index f3aec77e0b..267fc86462 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt @@ -11,26 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -expect fun Modifier.onRightClick(action: () -> Unit): Modifier - -expect interface DefaultExposedDropdownMenuBoxScope { - @Composable - open fun DefaultExposedDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit - ) -} - -@Composable -expect fun DefaultExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit -) - @Composable fun DefaultDropdownMenu( showMenu: MutableState, @@ -55,7 +35,7 @@ fun DefaultDropdownMenu( } @Composable -fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( +fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( expanded: MutableState, modifier: Modifier = Modifier, dropdownMenuItems: (@Composable () -> Unit)? @@ -63,7 +43,7 @@ fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( MaterialTheme( shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp))) ) { - DefaultExposedDropdownMenu( + ExposedDropdownMenu( modifier = Modifier .widthIn(min = 200.dp) .background(MaterialTheme.colors.surface) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index 2f24ff4144..72a8aaf10a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -29,7 +29,7 @@ fun ExposedDropDownSettingRow( ) { SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) { val expanded = remember { mutableStateOf(false) } - DefaultExposedDropdownMenuBox( + ExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value && enabled.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index c6ae0d6d48..16d7e88d6f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import chat.simplex.common.platform.onRightClick import chat.simplex.common.platform.windowWidth import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index eedf604a7f..5849178202 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -254,7 +254,7 @@ fun IntSettingRow(title: String, selection: MutableState, values: List Text(title) - DefaultExposedDropdownMenuBox( + ExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value @@ -313,7 +313,7 @@ fun TimeoutSettingRow(title: String, selection: MutableState, values: List Text(title) - DefaultExposedDropdownMenuBox( + ExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index 0185a50fcf..fa9f311d1b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -1,5 +1,6 @@ package chat.simplex.common.platform +import androidx.compose.foundation.contextMenuOpenDetector import androidx.compose.runtime.Composable import androidx.compose.ui.* import androidx.compose.ui.graphics.painter.Painter @@ -29,3 +30,5 @@ onExternalDrag(enabled) { is DragData.Text -> onText(data.readText()) } } + +actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index 7a46873f7d..8dac39199f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -6,9 +6,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp -import chat.simplex.common.platform.VideoPlayer -import chat.simplex.common.platform.isPlaying -import chat.simplex.common.views.helpers.onRightClick +import chat.simplex.common.platform.* @Composable actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 2b646f0e46..6c37c93ccc 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.helpers.* object NoIndication : Indication { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt deleted file mode 100644 index 5dfd44ba6b..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt +++ /dev/null @@ -1,44 +0,0 @@ -package chat.simplex.common.views.helpers - -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.material.DropdownMenu -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp - -actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() } - -actual interface DefaultExposedDropdownMenuBoxScope { - @Composable - actual fun DefaultExposedDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier, - content: @Composable ColumnScope.() -> Unit - ) { - DropdownMenu(expanded, onDismissRequest, offset = DpOffset(0.dp, (-40).dp)) { - Column { - content() - } - } - } -} - -@Composable -actual fun DefaultExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier, - content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit -) { - val obj = remember { object : DefaultExposedDropdownMenuBoxScope {} } - Box(Modifier - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { onExpandedChange(!expanded) }) - ) { - obj.content() - } -} diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index a7dab78ee8..ea808a32db 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -88,7 +88,7 @@ compose { notarization { this.appleID.set(appleId) this.password.set(password) - this.ascProvider.set(teamId) + this.teamID.set(teamId) } } } diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 3474208140..d1ce585346 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -33,4 +33,4 @@ desktop.version_code=15 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 -compose.version=1.4.3 +compose.version=1.5.10 diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 3680a4a2aa..c33f59253f 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -112,6 +112,11 @@ if [ -n "$LIBCRYPTO_PATH" ]; then install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsmplxmq*.$LIB_EXT fi +LIBCRYPTO_PATH=$(otool -l libHSsqlcphr-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsqlcphr-*.$LIB_EXT +fi + for lib in $(find . -type f -name "*.$LIB_EXT"); do RPATHS=`otool -l $lib | grep -E "path /Users/|path /usr/local|path /opt/" | cut -d' ' -f11` for RPATH in $RPATHS; do From 34b07d6a3b6ceda1faa3e160f8b700692ebb9d4a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 2 Nov 2023 10:44:24 +0000 Subject: [PATCH 139/294] core: update simplexmq (http2 lib update to fix sending files) --- cabal.project | 4 ++-- scripts/nix/sha256map.nix | 4 ++-- stack.yaml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cabal.project b/cabal.project index 9522fe2337..f84d427cfa 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 0410948b56ea630dfa86441bbcf8ec97aeb1df01 + tag: e9b5a849ab18de085e8c69d239a9706b99bcf787 source-repository-package type: git @@ -19,7 +19,7 @@ source-repository-package source-repository-package type: git location: https://github.com/kazu-yamamoto/http2.git - tag: 804fa283f067bd3fd89b8c5f8d25b3047813a517 + tag: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index ebce4b5c03..680b920754 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,7 @@ { - "https://github.com/simplex-chat/simplexmq.git"."0410948b56ea630dfa86441bbcf8ec97aeb1df01" = "1y4a28dkccbv8cbh164iirsnxa62qwac0pd5c8lqr5kddqvkz970"; + "https://github.com/simplex-chat/simplexmq.git"."e9b5a849ab18de085e8c69d239a9706b99bcf787" = "0b50mlnzwian4l9kx4niwnj9qkyp21ryc8x9d3il9jkdfxrx8kqi"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; - "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; + "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; diff --git a/stack.yaml b/stack.yaml index 7ab6474893..da69b9e905 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,9 +49,9 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 0410948b56ea630dfa86441bbcf8ec97aeb1df01 + commit: e9b5a849ab18de085e8c69d239a9706b99bcf787 - github: kazu-yamamoto/http2 - commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 + commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: f814ee68b16a9447fbb467ccc8f29bdd3546bfd9 From 0cc26d192d5a0f94338ee1584b2e38311d613b4d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 2 Nov 2023 14:07:51 +0000 Subject: [PATCH 140/294] update sha256map.nix --- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 680b920754..9b8d3d5f4b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e9b5a849ab18de085e8c69d239a9706b99bcf787" = "0b50mlnzwian4l9kx4niwnj9qkyp21ryc8x9d3il9jkdfxrx8kqi"; + "https://github.com/simplex-chat/simplexmq.git"."a5fed340e2814a226180ce1abe606ac79366fe5b" = "18sj1499rfb35wsgb9gbr3q99flhw650jm0wnn1iw42m9f17vwbn"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/stack.yaml b/stack.yaml index da69b9e905..fe6771b5cd 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: e9b5a849ab18de085e8c69d239a9706b99bcf787 + commit: a5fed340e2814a226180ce1abe606ac79366fe5b - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From c462dd37040ac4700cf0bdc41b60ad0e3139b5ad Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 3 Nov 2023 04:59:16 +0800 Subject: [PATCH 141/294] android, desktop: removed unused plugin (#3309) --- apps/multiplatform/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index 9d6fd0c20b..f404207529 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -45,7 +45,6 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle:${rootProject.extra["gradle.plugin.version"]}") classpath(kotlin("gradle-plugin", version = rootProject.extra["kotlin.version"] as String)) - classpath("org.jetbrains.kotlin:kotlin-serialization:1.3.2") classpath("dev.icerock.moko:resources-generator:0.23.0") // NOTE: Do not place your application dependencies here; they belong From 3d7258fa589b7ecf8f3126e6b861bd2ad81ff7e8 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 3 Nov 2023 07:11:26 +0800 Subject: [PATCH 142/294] android: fixed QR code sharing (#3311) * android: fixed QR code sharing * remove mime type change --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../kotlin/chat/simplex/common/platform/Share.android.kt | 2 +- .../kotlin/chat/simplex/common/views/helpers/Utils.android.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index a370bbf405..cf3fcbaae7 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -36,7 +36,7 @@ actual fun shareFile(text: String, fileSource: CryptoFile) { tmpFile.deleteOnExit() ChatModel.filesToDelete.add(tmpFile) decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) - FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(tmpFile.absolutePath)).toURI() + getAppFileUri(tmpFile.absolutePath) } else { getAppFileUri(fileSource.filePath) } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 127f13cd5c..18442d7791 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -152,7 +152,7 @@ private fun spannableStringToAnnotatedString( } actual fun getAppFileUri(fileName: String): URI = - FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI() + FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI() // https://developer.android.com/training/data-storage/shared/documents-files#bitmap actual fun getLoadedImage(file: CIFile?): Pair? { From 4816150b99e5bd5a6cf6c189a891a1f0f9468dac Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 3 Nov 2023 18:15:07 +0000 Subject: [PATCH 143/294] core: contacts without connections (#3313) * core: contacts without connections * compiles (some tests don't pass) * remove commented code * filter out user contact (fixes tests) --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- .../src/Broadcast/Bot.hs | 3 +- src/Simplex/Chat.hs | 214 ++++++++++-------- src/Simplex/Chat/Controller.hs | 5 +- src/Simplex/Chat/Protocol.hs | 2 +- src/Simplex/Chat/Store/Connections.hs | 5 +- src/Simplex/Chat/Store/Direct.hs | 37 +-- src/Simplex/Chat/Store/Files.hs | 10 +- src/Simplex/Chat/Store/Groups.hs | 27 ++- src/Simplex/Chat/Store/Messages.hs | 32 +-- src/Simplex/Chat/Store/Shared.hs | 17 +- src/Simplex/Chat/Types.hs | 23 +- src/Simplex/Chat/View.hs | 16 +- 12 files changed, 217 insertions(+), 174 deletions(-) diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index 04b6627f38..3495770d1c 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -62,7 +62,8 @@ broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _u MCLink {} -> True MCImage {} -> True _ -> False - broadcastTo ct'@Contact {activeConn = conn@Connection {connStatus}} = + broadcastTo Contact {activeConn = Nothing} = False + broadcastTo ct'@Contact {activeConn = Just conn@Connection {connStatus}} = (connStatus == ConnSndReady || connStatus == ConnReady) && not (connDisabled conn) && contactId' ct' /= contactId' ct diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 52bf4a1852..325b767efb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -614,8 +614,8 @@ processChatCommand = \case let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} chSize <- asks $ fileChunkSize . config - withStore' $ \db -> do - ft@FileTransferMeta {fileId} <- createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize subMode + withStore $ \db -> do + ft@FileTransferMeta {fileId} <- liftIO $ createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize subMode fileStatus <- case fileInline of Just IFMSent -> createSndDirectInlineFT db ct ft $> CIFSSndTransfer 0 1 _ -> pure CIFSSndStored @@ -749,7 +749,8 @@ processChatCommand = \case let fileSource = Just $ CryptoFile filePath cfArgs ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP} case contactOrGroup of - CGContact Contact {activeConn} -> withStore' $ \db -> createSndFTDescrXFTP db user Nothing activeConn ft fileDescr + CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> + withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft fileDescr CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) where -- we are not sending files to pending members, same as with inline files @@ -1190,7 +1191,8 @@ processChatCommand = \case ct <- getContact db user chatId liftIO $ updateContactSettings db user chatId chatSettings pure ct - withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (chatHasNtfs chatSettings) + forM_ (contactConnId ct) $ \connId -> + withAgent $ \a -> toggleConnectionNtfs a connId (chatHasNtfs chatSettings) ok user CTGroup -> do ms <- withStore $ \db -> do @@ -1211,9 +1213,12 @@ processChatCommand = \case ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact - ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId - incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - connectionStats <- withAgent (`getConnectionServers` contactConnId ct) + ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + incognitoProfile <- case activeConn of + Nothing -> pure Nothing + Just Connection {customUserProfileId} -> + forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + connectionStats <- mapM (withAgent . flip getConnectionServers) (contactConnId ct) pure $ CRContactInfo user ct connectionStats (fmap fromLocalProfile incognitoProfile) APIGroupInfo gId -> withUser $ \user -> do (g, s) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> liftIO (getGroupSummary db user gId) @@ -1224,8 +1229,11 @@ processChatCommand = \case pure $ CRGroupMemberInfo user g m connectionStats APISwitchContact contactId -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId - connectionStats <- withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct - pure $ CRContactSwitchStarted user ct connectionStats + case contactConnId ct of + Just connId -> do + connectionStats <- withAgent $ \a -> switchConnectionAsync a "" connId + pure $ CRContactSwitchStarted user ct connectionStats + Nothing -> throwChatError $ CEContactNotActive ct APISwitchGroupMember gId gMemberId -> withUser $ \user -> do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId case memberConnId m of @@ -1235,8 +1243,11 @@ processChatCommand = \case _ -> throwChatError CEGroupMemberNotActive APIAbortSwitchContact contactId -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId - connectionStats <- withAgent $ \a -> abortConnectionSwitch a $ contactConnId ct - pure $ CRContactSwitchAborted user ct connectionStats + case contactConnId ct of + Just connId -> do + connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId + pure $ CRContactSwitchAborted user ct connectionStats + Nothing -> throwChatError $ CEContactNotActive ct APIAbortSwitchGroupMember gId gMemberId -> withUser $ \user -> do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId case memberConnId m of @@ -1246,9 +1257,12 @@ processChatCommand = \case _ -> throwChatError CEGroupMemberNotActive APISyncContactRatchet contactId force -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId - cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (contactConnId ct) force - createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing - pure $ CRContactRatchetSyncStarted user ct cStats + case contactConnId ct of + Just connId -> do + cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId force + createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing + pure $ CRContactRatchetSyncStarted user ct cStats + Nothing -> throwChatError $ CEContactNotActive ct APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId case memberConnId m of @@ -1258,16 +1272,19 @@ processChatCommand = \case pure $ CRGroupMemberRatchetSyncStarted user g m cStats _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do - ct@Contact {activeConn = conn@Connection {connId}} <- withStore $ \db -> getContact db user contactId - code <- getConnectionCode (contactConnId ct) - ct' <- case contactSecurityCode ct of - Just SecurityCode {securityCode} - | sameVerificationCode code securityCode -> pure ct - | otherwise -> do - withStore' $ \db -> setConnectionVerified db user connId Nothing - pure (ct :: Contact) {activeConn = conn {connectionCode = Nothing}} - _ -> pure ct - pure $ CRContactCode user ct' code + ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + case activeConn of + Just conn@Connection {connId} -> do + code <- getConnectionCode $ aConnId conn + ct' <- case contactSecurityCode ct of + Just SecurityCode {securityCode} + | sameVerificationCode code securityCode -> pure ct + | otherwise -> do + withStore' $ \db -> setConnectionVerified db user connId Nothing + pure (ct :: Contact) {activeConn = Just $ (conn :: Connection) {connectionCode = Nothing}} + _ -> pure ct + pure $ CRContactCode user ct' code + Nothing -> throwChatError $ CEContactNotActive ct APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do (g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId case activeConn of @@ -1283,17 +1300,22 @@ processChatCommand = \case pure $ CRGroupMemberCode user g m' code _ -> throwChatError CEGroupMemberNotActive APIVerifyContact contactId code -> withUser $ \user -> do - Contact {activeConn} <- withStore $ \db -> getContact db user contactId - verifyConnectionCode user activeConn code + ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + case activeConn of + Just conn -> verifyConnectionCode user conn code + Nothing -> throwChatError $ CEContactNotActive ct APIVerifyGroupMember gId gMemberId code -> withUser $ \user -> do GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId case activeConn of Just conn -> verifyConnectionCode user conn code _ -> throwChatError CEGroupMemberNotActive APIEnableContact contactId -> withUser $ \user -> do - Contact {activeConn} <- withStore $ \db -> getContact db user contactId - withStore' $ \db -> setConnectionAuthErrCounter db user activeConn 0 - ok user + ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId + case activeConn of + Just conn -> do + withStore' $ \db -> setConnectionAuthErrCounter db user conn 0 + ok user + Nothing -> throwChatError $ CEContactNotActive ct APIEnableGroupMember gId gMemberId -> withUser $ \user -> do GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId case activeConn of @@ -1554,16 +1576,19 @@ processChatCommand = \case inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId (inv,) <$> getContactViaMember db user fromMember let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation - Contact {activeConn = Connection {peerChatVRange}} = ct - subMode <- chatReadVar subscriptionMode - dm <- directMessage $ XGrpAcpt membership.memberId - agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode - withStore' $ \db -> do - createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode - updateGroupMemberStatus db userId fromMember GSMemAccepted - updateGroupMemberStatus db userId membership GSMemAccepted - updateCIGroupInvitationStatus user - pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing + Contact {activeConn} = ct + case activeConn of + Just Connection {peerChatVRange} -> do + subMode <- chatReadVar subscriptionMode + dm <- directMessage $ XGrpAcpt membership.memberId + agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode + withStore' $ \db -> do + createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode + updateGroupMemberStatus db userId fromMember GSMemAccepted + updateGroupMemberStatus db userId membership GSMemAccepted + updateCIGroupInvitationStatus user + pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing + Nothing -> throwChatError $ CEContactNotActive ct where updateCIGroupInvitationStatus user = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId @@ -2064,7 +2089,8 @@ processChatCommand = \case void $ sendDirectContactMessage ct' (XInfo mergedProfile') when (directOrUsed ct') $ createSndFeatureItems user' ct ct' updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse - updateContactPrefs user@User {userId} ct@Contact {activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' + updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct + updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' | contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated user ct ct | otherwise = do assertDirectAllowed user MDSnd ct XInfo_ @@ -2595,8 +2621,8 @@ acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvI let profileToSend = profileToSendOnAccept user incognitoProfile (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode withStore' $ \db -> do - ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode - setCommandConnId db user cmdId connId + ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode + forM_ activeConn $ \Connection {connId} -> setCommandConnId db user cmdId connId pure ct acceptGroupJoinRequestAsync :: ChatMonad m => User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> Maybe IncognitoProfile -> m GroupMember @@ -2717,7 +2743,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts - let connIds = map contactConnId (filter contactActive cts) + let connIds = catMaybes $ map contactConnId (filter contactActive cts) pure (connIds, M.fromList $ zip connIds cts) getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact) getUserContactLinkConns = do @@ -2758,9 +2784,10 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do toView $ CRNetworkStatuses (Just user) $ map (uncurry ConnNetworkStatus) statuses where addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] - addStatus connId ct = - let ns = (contactAgentConnId ct, netStatus $ resultErr connId rs) - in (ns :) + addStatus _ Contact {activeConn = Nothing} nss = nss + addStatus connId Contact {activeConn = Just Connection {agentConnId}} nss = + let ns = (agentConnId, netStatus $ resultErr connId rs) + in ns : nss netStatus :: Maybe ChatError -> NetworkStatus netStatus = maybe NSConnected $ NSError . errorNetworkStatus errorNetworkStatus :: ChatError -> String @@ -3203,7 +3230,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody cmdId - let ct' = ct {activeConn = conn'} :: Contact + let ct' = ct {activeConn = Just conn'} :: Contact assertDirectAllowed user MDRcv ct' $ toCMEventTag event updateChatLock "directMessage" event case event of @@ -3311,7 +3338,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (RSAllowed, _, Just cryptoErr) -> processErr cryptoErr (RSAgreed, Just _, _) -> do withStore' $ \db -> setConnectionVerified db user connId Nothing - let ct' = ct {activeConn = conn {connectionCode = Nothing}} :: Contact + let ct' = ct {activeConn = Just $ (conn :: Connection) {connectionCode = Nothing}} :: Contact ratchetSyncEventItem ct' securityCodeChanged ct' _ -> ratchetSyncEventItem ct @@ -3464,11 +3491,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership when (memberCategory m == GCPreMember) $ probeMatchingMemberContact m connectedIncognito - Just ct@Contact {activeConn = Connection {connStatus}} -> - when (connStatus == ConnReady) $ do - notifyMemberConnected gInfo m $ Just ct - let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True + Just ct@Contact {activeConn} -> + forM_ activeConn $ \Connection {connStatus} -> + when (connStatus == ConnReady) $ do + notifyMemberConnected gInfo m $ Just ct + let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do @@ -4279,7 +4307,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> do event <- withStore $ \db -> do ci' <- updateDirectCIFileStatus db user fileId $ CIFSSndTransfer 0 1 - sft <- liftIO $ createSndDirectInlineFT db ct ft + sft <- createSndDirectInlineFT db ct ft pure $ CRSndFileStart user ci' sft toView event ifM @@ -4395,30 +4423,31 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () processGroupInvitation ct inv msg msgMeta = do - let Contact {localDisplayName = c, activeConn = Connection {connId, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct + let Contact {localDisplayName = c, activeConn} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv - checkIntegrityCreateItem (CDDirectRcv ct) msgMeta - when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) - when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId - -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile - (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId - if sameGroupLinkId groupLinkId groupLinkId' - then do - subMode <- chatReadVar subscriptionMode - dm <- directMessage $ XGrpAcpt memberId - connIds <- joinAgentConnectionAsync user True connRequest dm subMode - withStore' $ \db -> do - setViaGroupLinkHash db groupId connId - createMemberConnectionAsync db user hostId connIds (fromJVersionRange peerChatVRange) subMode - updateGroupMemberStatusById db userId hostId GSMemAccepted - updateGroupMemberStatus db userId membership GSMemAccepted - toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) - else do - let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content - withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) - toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} + forM_ activeConn $ \Connection {connId, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta + when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) + when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId + -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile + (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId + if sameGroupLinkId groupLinkId groupLinkId' + then do + subMode <- chatReadVar subscriptionMode + dm <- directMessage $ XGrpAcpt memberId + connIds <- joinAgentConnectionAsync user True connRequest dm subMode + withStore' $ \db -> do + setViaGroupLinkHash db groupId connId + createMemberConnectionAsync db user hostId connIds (fromJVersionRange peerChatVRange) subMode + updateGroupMemberStatusById db userId hostId GSMemAccepted + updateGroupMemberStatus db userId membership GSMemAccepted + toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) + else do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole + ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool sameGroupLinkId (Just gli) (Just gli') = gli == gli' @@ -4441,7 +4470,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do contactConns <- withStore $ \db -> getContactConnections db userId ct' deleteAgentConnectionsAsync user $ map aConnId contactConns forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted - let ct'' = ct' {activeConn = (contactConn ct') {connStatus = ConnDeleted}} :: Contact + activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} + let ct'' = ct' {activeConn = activeConn'} :: Contact ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) toView $ CRContactDeletedByContact user ct'' @@ -4951,20 +4981,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> createNewContact subMode Just mContactId -> do mCt <- withStore $ \db -> getContact db user mContactId - let Contact {activeConn = Connection {connId}, contactGrpInvSent} = mCt - if contactGrpInvSent - then do - ownConnReq <- withStore $ \db -> getConnReqInv db connId - -- in case both members sent x.grp.direct.inv before receiving other's for processing, - -- only the one who received greater connReq joins, the other creates items and waits for confirmation - if strEncode connReq > strEncode ownConnReq - then joinExistingContact subMode mCt - else createItems mCt m - else joinExistingContact subMode mCt + let Contact {activeConn, contactGrpInvSent} = mCt + forM_ activeConn $ \Connection {connId} -> + if contactGrpInvSent + then do + ownConnReq <- withStore $ \db -> getConnReqInv db connId + -- in case both members sent x.grp.direct.inv before receiving other's for processing, + -- only the one who received greater connReq joins, the other creates items and waits for confirmation + if strEncode connReq > strEncode ownConnReq + then joinExistingContact subMode mCt + else createItems mCt m + else joinExistingContact subMode mCt where joinExistingContact subMode mCt = do connIds <- joinConn subMode - mCt' <- withStore' $ \db -> updateMemberContactInvited db user connIds g mConn mCt subMode + mCt' <- withStore $ \db -> updateMemberContactInvited db user connIds g mConn mCt subMode createItems mCt' m securityCodeChanged mCt' createNewContact subMode = do @@ -5054,7 +5085,7 @@ parseFileDescription = sendDirectFileInline :: ChatMonad m => Contact -> FileTransferMeta -> SharedMsgId -> m () sendDirectFileInline ct ft sharedMsgId = do msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage ct - withStore' $ \db -> updateSndDirectFTDelivery db ct ft msgDeliveryId + withStore $ \db -> updateSndDirectFTDelivery db ct ft msgDeliveryId sendMemberFileInline :: ChatMonad m => GroupMember -> Connection -> FileTransferMeta -> SharedMsgId -> m () sendMemberFileInline m@GroupMember {groupId} conn ft sharedMsgId = do @@ -5247,7 +5278,8 @@ deleteOrUpdateMemberRecord user@User {userId} member = Nothing -> deleteGroupMember db user member sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64) -sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}, contactStatus} chatMsgEvent +sendDirectContactMessage ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotReady ct +sendDirectContactMessage ct@Contact {activeConn = Just conn@Connection {connId, connStatus}, contactStatus} chatMsgEvent | connStatus /= ConnReady && connStatus /= ConnSndReady = throwChatError $ CEContactNotReady ct | contactStatus /= CSActive = throwChatError $ CEContactNotActive ct | connDisabled conn = throwChatError $ CEContactDisabled ct diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 5f386dea0d..c62569235d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -436,7 +436,7 @@ data ChatResponse | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} - | CRContactInfo {user :: User, contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} + | CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile} | CRGroupInfo {user :: User, groupInfo :: GroupInfo, groupSummary :: GroupSummary} | CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} | CRContactSwitchStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} @@ -1064,7 +1064,8 @@ chatModifyVar f newValue = asks f >>= atomically . (`modifyTVar'` newValue) {-# INLINE chatModifyVar #-} setContactNetworkStatus :: ChatMonad' m => Contact -> NetworkStatus -> m () -setContactNetworkStatus ct = chatModifyVar connNetworkStatuses . M.insert (contactAgentConnId ct) +setContactNetworkStatus Contact {activeConn = Nothing} _ = pure () +setContactNetworkStatus Contact {activeConn = Just Connection {agentConnId}} status = chatModifyVar connNetworkStatuses $ M.insert agentConnId status tryChatError :: ChatMonad m => m a -> m (Either ChatError a) tryChatError = tryAllErrors mkChatError diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 58aa26f284..43ca5913f7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -82,7 +82,7 @@ instance ToJSON ConnectionEntity where updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity updateEntityConnStatus connEntity connStatus = case connEntity of - RcvDirectMsgConnection c ct_ -> RcvDirectMsgConnection (st c) ((\ct -> (ct :: Contact) {activeConn = st c}) <$> ct_) + RcvDirectMsgConnection c ct_ -> RcvDirectMsgConnection (st c) ((\ct -> (ct :: Contact) {activeConn = Just $ st c}) <$> ct_) RcvGroupMsgConnection c gInfo m@GroupMember {activeConn = c'} -> RcvGroupMsgConnection (st c) gInfo m {activeConn = st <$> c'} SndFileConnection c ft -> SndFileConnection (st c) ft RcvFileConnection c ft -> RcvFileConnection (st c) ft diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index d73ac705d3..b5b377ea51 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -81,10 +81,11 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do |] (userId, contactId) toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact - toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = + toContact' contactId conn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} - mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn + mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn + activeConn = Just conn in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 477361acdf..ba420b9806 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -194,13 +194,13 @@ createIncognitoProfile db User {userId} p = do createIncognitoProfile_ db userId createdAt p createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact -createDirectContact db user@User {userId} activeConn@Connection {connId, localAlias} p@Profile {preferences} = do +createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} = do createdAt <- liftIO getCurrentTime (localDisplayName, contactId, profileId) <- createContact_ db userId connId p localAlias Nothing createdAt (Just createdAt) let profile = toLocalProfile profileId p localAlias userPreferences = emptyChatPrefs - mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} + mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn + pure $ Contact {contactId, localDisplayName, profile, activeConn = Just conn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO () deleteContactConnectionsAndFiles db userId Contact {contactId} = do @@ -218,7 +218,7 @@ deleteContactConnectionsAndFiles db userId Contact {contactId} = do DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId) deleteContact :: DB.Connection -> User -> Contact -> IO () -deleteContact db user@User {userId} Contact {contactId, localDisplayName, activeConn = Connection {customUserProfileId}} = do +deleteContact db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) if isNothing ctMember @@ -229,16 +229,20 @@ deleteContact db user@User {userId} Contact {contactId, localDisplayName, active currentTs <- getCurrentTime DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) - forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId + forM_ activeConn $ \Connection {customUserProfileId} -> + forM_ customUserProfileId $ \profileId -> + deleteUnusedIncognitoProfileById_ db user profileId -- should only be used if contact is not member of any groups deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> IO () -deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn = Connection {customUserProfileId}} = do +deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) deleteContactProfile_ db userId contactId DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) - forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId + forM_ activeConn $ \Connection {customUserProfileId} -> + forM_ customUserProfileId $ \profileId -> + deleteUnusedIncognitoProfileById_ db user profileId setContactDeleted :: DB.Connection -> User -> Contact -> IO () setContactDeleted db User {userId} Contact {contactId} = do @@ -307,19 +311,19 @@ updateContactProfile db user@User {userId} c p' updateContact_ db userId contactId localDisplayName ldn currentTs pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} where - Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, activeConn, userPreferences} = c + Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c Profile {displayName = newName, preferences} = p' profile = toLocalProfile profileId p' localAlias - mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn + mergedPreferences = contactUserPreferences user userPreferences preferences $ contactConnIncognito c updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact -updateContactUserPreferences db user@User {userId} c@Contact {contactId, activeConn} userPreferences = do +updateContactUserPreferences db user@User {userId} c@Contact {contactId} userPreferences = do updatedAt <- getCurrentTime DB.execute db "UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (userPreferences, updatedAt, userId, contactId) - let mergedPreferences = contactUserPreferences user userPreferences (preferences' c) $ connIncognito activeConn + let mergedPreferences = contactUserPreferences user userPreferences (preferences' c) $ contactConnIncognito c pure $ c {mergedPreferences, userPreferences} updateContactAlias :: DB.Connection -> UserId -> Contact -> LocalAlias -> IO Contact @@ -453,7 +457,8 @@ getContactByName db user localDisplayName = do getUserContacts :: DB.Connection -> User -> IO [Contact] getUserContacts db user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId) - rights <$> mapM (runExceptT . getContact db user) contactIds + contacts <- rights <$> mapM (runExceptT . getContact db user) contactIds + pure $ filter (\Contact {activeConn} -> isJust activeConn) contacts createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRange -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ = @@ -642,9 +647,9 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id) VALUES (?,?,?,?,?,?,?,?,?)" (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId) contactId <- insertedRowId db - activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode - let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} + conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode + let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn + pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn = Just conn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 getContactIdByName db User {userId} cName = @@ -656,7 +661,7 @@ getContact db user contactId = getContact_ db user contactId False getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact getContact_ db user@User {userId} contactId deleted = - ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $ + ExceptT . firstRow (toContact user) (SEContactNotFound contactId) $ DB.query db [sql| diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index a710696dad..7d950b25f3 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -207,8 +207,9 @@ createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs) -createSndDirectInlineFT :: DB.Connection -> Contact -> FileTransferMeta -> IO SndFileTransfer -createSndDirectInlineFT db Contact {localDisplayName = n, activeConn = Connection {connId, agentConnId}} FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, fileInline} = do +createSndDirectInlineFT :: DB.Connection -> Contact -> FileTransferMeta -> ExceptT StoreError IO SndFileTransfer +createSndDirectInlineFT _ Contact {localDisplayName, activeConn = Nothing} _ = throwError $ SEContactNotReady localDisplayName +createSndDirectInlineFT db Contact {localDisplayName = n, activeConn = Just Connection {connId, agentConnId}} FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, fileInline} = liftIO $ do currentTs <- getCurrentTime let fileStatus = FSConnected fileInline' = Just $ fromMaybe IFMOffer fileInline @@ -229,8 +230,9 @@ createSndGroupInlineFT db GroupMember {groupMemberId, localDisplayName = n} Conn (fileId, fileStatus, fileInline', connId, groupMemberId, currentTs, currentTs) pure SndFileTransfer {fileId, fileName, filePath, fileSize, chunkSize, recipientDisplayName = n, connId, agentConnId, groupMemberId = Just groupMemberId, fileStatus, fileDescrId = Nothing, fileInline = fileInline'} -updateSndDirectFTDelivery :: DB.Connection -> Contact -> FileTransferMeta -> Int64 -> IO () -updateSndDirectFTDelivery db Contact {activeConn = Connection {connId}} FileTransferMeta {fileId} msgDeliveryId = +updateSndDirectFTDelivery :: DB.Connection -> Contact -> FileTransferMeta -> Int64 -> ExceptT StoreError IO () +updateSndDirectFTDelivery _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName +updateSndDirectFTDelivery db Contact {activeConn = Just Connection {connId}} FileTransferMeta {fileId} msgDeliveryId = liftIO $ DB.execute db "UPDATE snd_files SET last_inline_msg_delivery_id = ? WHERE connection_id = ? AND file_id = ? AND file_inline IS NOT NULL" diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index bddca0deb4..09c59eee6d 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -314,7 +314,8 @@ createNewGroup db gVar user@User {userId} groupProfile incognitoProfile = Except -- | creates a new group record for the group the current user was invited to, or returns an existing one createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) -createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do +createGroupInvitation _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName +createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do @@ -705,7 +706,8 @@ getGroupInvitation db user groupId = DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?" (groupId, userId) createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember -createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Connection {peerChatVRange}} memberRole agentConnId connRequest subMode = +createNewContactMember _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ _ = throwError $ SEContactNotReady localDisplayName +createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Just Connection {peerChatVRange}} memberRole agentConnId connRequest subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt @@ -1725,15 +1727,15 @@ createMemberContact connId <- insertedRowId db let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = True, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} getMemberContact :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db user contactId = do ct <- getContact db user contactId - let Contact {contactGroupMemberId, activeConn = Connection {connId}} = ct - cReq <- getConnReqInv db connId - case contactGroupMemberId of - Just groupMemberId -> do + let Contact {contactGroupMemberId, activeConn} = ct + case (activeConn, contactGroupMemberId) of + (Just Connection {connId}, Just groupMemberId) -> do + cReq <- getConnReqInv db connId m@GroupMember {groupId} <- getGroupMemberById db user groupMemberId g <- getGroupInfo db user groupId pure (g, m, ct, cReq) @@ -1762,7 +1764,7 @@ createMemberContactInvited contactId <- createContactUpdateMember currentTs userPreferences ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} + mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} m' = m {memberContactId = Just contactId} pure (mCt', m') where @@ -1786,13 +1788,14 @@ createMemberContactInvited (contactId, currentTs, groupMemberId) pure contactId -updateMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> GroupInfo -> Connection -> Contact -> SubscriptionMode -> IO Contact -updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = oldContactConn} subMode = do +updateMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> GroupInfo -> Connection -> Contact -> SubscriptionMode -> ExceptT StoreError IO Contact +updateMemberContactInvited _ _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ = throwError $ SEContactNotReady localDisplayName +updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = Just oldContactConn} subMode = liftIO $ do updateConnectionStatus db oldContactConn ConnDeleted - activeConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode + activeConn' <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode ct' <- updateContactStatus db user ct CSActive ct'' <- resetMemberContactFields db ct' - pure (ct'' :: Contact) {activeConn} + pure (ct'' :: Contact) {activeConn = Just activeConn'} resetMemberContactFields :: DB.Connection -> Contact -> IO Contact resetMemberContactFields db ct@Contact {contactId} = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 35a8bad698..0136ac6609 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -497,7 +497,7 @@ getDirectChatPreviews_ db user@User {userId} = do ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - JOIN connections c ON c.contact_id = ct.contact_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id LEFT JOIN ( SELECT contact_id, MAX(chat_item_id) AS MaxId FROM chat_items @@ -514,25 +514,31 @@ getDirectChatPreviews_ db user@User {userId} = do ) ChatStats ON ChatStats.contact_id = ct.contact_id LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id WHERE ct.user_id = ? - AND ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1) + AND ct.is_user = 0 AND ct.deleted = 0 - AND c.connection_id = ( - SELECT cc_connection_id FROM ( - SELECT - cc.connection_id AS cc_connection_id, - cc.created_at AS cc_created_at, - (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord - FROM connections cc - WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_created_at DESC - LIMIT 1 + AND ( + ( + ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1) + AND c.connection_id = ( + SELECT cc_connection_id FROM ( + SELECT + cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, + (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord + FROM connections cc + WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC + LIMIT 1 + ) + ) ) + OR c.connection_id IS NULL ) ORDER BY i.item_ts DESC |] (CISRcvNew, userId, ConnReady, ConnSndReady) where - toDirectChatPreview :: UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat + toDirectChatPreview :: UTCTime -> ContactRow :. MaybeConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat toDirectChatPreview currentTs (contactRow :. connRow :. statsRow :. ciRow_) = let contact = toContact user $ contactRow :. connRow ci_ = toDirectChatItemList currentTs ciRow_ diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 2ad447aa8e..3f7378969c 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -254,24 +254,15 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) -toContact :: User -> ContactRow :. ConnectionRow -> Contact +toContact :: User -> ContactRow :. MaybeConnectionRow -> Contact toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - activeConn = toConnection connRow + activeConn = toMaybeConnection connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} - mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn + incognito = maybe False connIncognito activeConn + mergedPreferences = contactUserPreferences user userPreferences preferences incognito in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} -toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact -toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = - let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} - in case toMaybeConnection connRow of - Just activeConn -> - let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} - _ -> Left $ SEContactNotReady localDisplayName - getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile getProfileById db userId profileId = ExceptT . firstRow toProfile (SEProfileNotFound profileId) $ diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 23ed608639..c92b25fb20 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -170,7 +170,7 @@ data Contact = Contact { contactId :: ContactId, localDisplayName :: ContactName, profile :: LocalProfile, - activeConn :: Connection, + activeConn :: Maybe Connection, viaGroup :: Maybe Int64, contactUsed :: Bool, contactStatus :: ContactStatus, @@ -189,32 +189,31 @@ instance ToJSON Contact where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} -contactConn :: Contact -> Connection +contactConn :: Contact -> Maybe Connection contactConn Contact {activeConn} = activeConn -contactAgentConnId :: Contact -> AgentConnId -contactAgentConnId Contact {activeConn = Connection {agentConnId}} = agentConnId - -contactConnId :: Contact -> ConnId -contactConnId = aConnId . contactConn +contactConnId :: Contact -> Maybe ConnId +contactConnId c = aConnId <$> contactConn c type IncognitoEnabled = Bool contactConnIncognito :: Contact -> IncognitoEnabled -contactConnIncognito = connIncognito . contactConn +contactConnIncognito = maybe False connIncognito . contactConn contactDirect :: Contact -> Bool -contactDirect Contact {activeConn = Connection {connLevel, viaGroupLink}} = connLevel == 0 && not viaGroupLink +contactDirect Contact {activeConn} = maybe True direct activeConn + where + direct Connection {connLevel, viaGroupLink} = connLevel == 0 && not viaGroupLink directOrUsed :: Contact -> Bool directOrUsed ct@Contact {contactUsed} = contactDirect ct || contactUsed anyDirectOrUsed :: Contact -> Bool -anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed +anyDirectOrUsed Contact {contactUsed, activeConn} = ((\c -> c.connLevel) <$> activeConn) == Just 0 || contactUsed contactReady :: Contact -> Bool -contactReady Contact {activeConn} = connReady activeConn +contactReady Contact {activeConn} = maybe False connReady activeConn contactActive :: Contact -> Bool contactActive Contact {contactStatus} = contactStatus == CSActive @@ -223,7 +222,7 @@ contactDeleted :: Contact -> Bool contactDeleted Contact {contactStatus} = contactStatus == CSDeleted contactSecurityCode :: Contact -> Maybe SecurityCode -contactSecurityCode Contact {activeConn} = connectionCode activeConn +contactSecurityCode Contact {activeConn} = connectionCode =<< activeConn data ContactStatus = CSActive diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8494a7fc18..f1da124972 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -137,9 +137,11 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRGroupsList u gs -> ttyUser u $ viewGroupsList gs CRSentGroupInvitation u g c _ -> ttyUser u $ - if viaGroupLink . contactConn $ c - then [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] - else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] + case contactConn c of + Just Connection {viaGroupLink} + | viaGroupLink -> [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] + | otherwise -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] + Nothing -> [] CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci CRUserProfile u p -> ttyUser u $ viewUserProfile p @@ -325,7 +327,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView testViewChats chats = [sShow $ map toChatView chats] where toChatView :: AChat -> (Text, Text, Maybe ConnStatus) - toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, Just $ connStatus activeConn) + toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) @@ -1038,10 +1040,10 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} = "use " <> highlight' "/network socks=[ timeout=]" <> " to change settings" ] -viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString] +viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn} stats incognitoProfile = ["contact ID: " <> sShow contactId] - <> viewConnectionStats stats + <> maybe [] viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink <> maybe ["you've shared main profile with this contact"] @@ -1049,7 +1051,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta incognitoProfile <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (contactSecurityCode ct)] - <> [viewPeerChatVRange (peerChatVRange activeConn)] + <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] viewGroupInfo GroupInfo {groupId} s = From 1f5b80d56051ae6f9503be1de335766e2ba00e2a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 4 Nov 2023 13:37:25 +0000 Subject: [PATCH 144/294] fix for ghc8107 --- src/Simplex/Chat/Types.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 532b442b07..2ba382515b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -208,7 +208,7 @@ directOrUsed ct@Contact {contactUsed} = contactDirect ct || contactUsed anyDirectOrUsed :: Contact -> Bool -anyDirectOrUsed Contact {contactUsed, activeConn} = ((\c -> c.connLevel) <$> activeConn) == Just 0 || contactUsed +anyDirectOrUsed Contact {contactUsed, activeConn} = ((\Connection {connLevel} -> connLevel) <$> activeConn) == Just 0 || contactUsed contactReady :: Contact -> Bool contactReady Contact {activeConn} = maybe False connReady activeConn From 10cbb13c2600e41cb6dd63ca23ca7352cff36ba1 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 5 Nov 2023 00:59:07 +0800 Subject: [PATCH 145/294] desktop: screen sharing in video calls (#3310) * desktop: screen sharing * use async function * fit/fill of the video * disconnect camera button from screen share * enable video on audio call * temp * Revert "temp" This reverts commit 8f8a2f7f8820852253c1258234c51e33108424dd. * Revert "enable video on audio call" This reverts commit 120068d09afd78d65d14b1063d8f7bb9a6b04695. * different logic --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../simplex/common/views/chat/ChatView.kt | 47 +++++++---- .../resources/assets/www/android/call.html | 1 + .../commonMain/resources/assets/www/call.js | 77 ++++++++++++++++- .../resources/assets/www/desktop/call.html | 6 +- .../www/desktop/images/ic_screen_share.svg | 1 + .../desktop/images/ic_stop_screen_share.svg | 1 + .../resources/assets/www/desktop/ui.js | 23 ++++- .../simplex-chat-webrtc/src/android/call.html | 1 + packages/simplex-chat-webrtc/src/call.ts | 83 ++++++++++++++++++- .../simplex-chat-webrtc/src/desktop/call.html | 6 +- .../src/desktop/images/ic_screen_share.svg | 1 + .../desktop/images/ic_stop_screen_share.svg | 1 + .../simplex-chat-webrtc/src/desktop/ui.ts | 23 ++++- 13 files changed, 243 insertions(+), 28 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_screen_share.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_stop_screen_share.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_screen_share.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_stop_screen_share.svg diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index e724dfd7cb..2e46a13870 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -589,18 +589,30 @@ fun ChatInfoToolbar( if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) { if (activeCall == null) { barButtons.add { - IconButton( - { + if (appPlatform.isAndroid) { + IconButton({ showMenu.value = false startCall(CallMediaType.Audio) - }, - enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active - ) { - Icon( - painterResource(MR.images.ic_call_500), - stringResource(MR.strings.icon_descr_more_button), - tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) + }, enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active + ) { + Icon( + painterResource(MR.images.ic_call_500), + stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), + tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } else { + IconButton({ + showMenu.value = false + startCall(CallMediaType.Video) + }, enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active + ) { + Icon( + painterResource(MR.images.ic_videocam), + stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), + tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } } } } else if (activeCall?.contact?.id == chat.id) { @@ -634,10 +646,17 @@ fun ChatInfoToolbar( } if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active && activeCall == null) { menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { - showMenu.value = false - startCall(CallMediaType.Video) - }) + if (appPlatform.isAndroid) { + ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { + showMenu.value = false + startCall(CallMediaType.Video) + }) + } else { + ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = { + showMenu.value = false + startCall(CallMediaType.Audio) + }) + } } } } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) { diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html index 46910bfaf1..7b51a05151 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html @@ -11,6 +11,7 @@ autoplay playsinline poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII=" + onclick="javascript:toggleRemoteVideoFitFill()" > Toggle incognito when connecting. + Activa incógnito al conectar. No comment provided by engineer. @@ -4826,6 +4979,18 @@ Se te pedirá que completes la autenticación antes de activar esta función.No se puede grabar mensaje de voz No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Error inesperado: %@ @@ -5174,6 +5339,35 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Ya estás conectado a %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Estás conectado al servidor usado para recibir mensajes de este contacto. @@ -5269,6 +5463,15 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb No has podido ser autenticado. Inténtalo de nuevo. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats No tienes chats @@ -5319,6 +5522,10 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Te conectarás al grupo cuando el dispositivo del anfitrión esté en línea, por favor espera o compruébalo más tarde. No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Te conectarás cuando tu solicitud se acepte, por favor espera o compruébalo más tarde. @@ -5334,9 +5541,8 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Se te pedirá identificarte cuándo inicies o continues usando la aplicación tras 30 segundos en segundo plano. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Te unirás al grupo al que hace referencia este enlace y te conectarás con sus miembros. + + You will connect to all group members. No comment provided by engineer. @@ -5404,11 +5610,6 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb La base de datos no está cifrada - establece una contraseña para cifrarla. No comment provided by engineer. - - Your chat profile will be sent to group members - Tu perfil será enviado a los miembros del grupo - No comment provided by engineer. - Your chat profiles Mis perfiles @@ -5463,6 +5664,10 @@ Puedes cambiarlo en Configuración. Privacidad No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Tu perfil **%@** será compartido. @@ -5555,6 +5760,10 @@ Los servidores de SimpleX no pueden ver tu perfil. siempre pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) llamada (sin cifrar) @@ -5570,6 +5779,10 @@ Los servidores de SimpleX no pueden ver tu perfil. hash de mensaje erróneo integrity error chat item + + blocked + No comment provided by engineer. + bold negrita @@ -5642,6 +5855,7 @@ Los servidores de SimpleX no pueden ver tu perfil. connected directly + conectado directamente rcv group event chat item @@ -5739,6 +5953,10 @@ Los servidores de SimpleX no pueden ver tu perfil. eliminado deleted chat item + + deleted contact + rcv direct event chat item + deleted group grupo eliminado @@ -6023,7 +6241,8 @@ Los servidores de SimpleX no pueden ver tu perfil. off desactivado enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6040,11 +6259,6 @@ Los servidores de SimpleX no pueden ver tu perfil. Activado group pref value - - or chat with the developers - o contacta mediante Chat con los desarrolladores - No comment provided by engineer. - owner propietario @@ -6107,6 +6321,7 @@ Los servidores de SimpleX no pueden ver tu perfil. send direct message + Enviar mensaje directo No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index c7e970f6ff..10e249cfdf 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -87,6 +87,10 @@ %@ / % @ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ ja %@ yhdistetty @@ -97,6 +101,10 @@ %1$@ klo %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ on yhdistetty! @@ -122,6 +130,10 @@ %@ haluaa muodostaa yhteyden! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ ja %lld muut jäsenet yhdistetty @@ -187,11 +199,27 @@ %lld tiedosto(a), joiden kokonaiskoko on %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld jäsenet No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minuuttia @@ -199,6 +227,7 @@ %lld new interface languages + %lld uutta käyttöliittymän kieltä No comment provided by engineer. @@ -360,6 +389,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -585,6 +618,10 @@ Kaikki viestit poistetaan - tätä ei voi kumota! Viestit poistuvat VAIN sinulta. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Kaikki kontaktisi pysyvät yhteydessä. @@ -690,6 +727,14 @@ Oletko jo muodostanut yhteyden? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Käytä aina relettä @@ -824,6 +869,18 @@ Parempia viestejä No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Sekä sinä että kontaktisi voivat käyttää viestireaktioita. @@ -1084,24 +1141,27 @@ Yhdistä server test step - - Connect directly - Yhdistä suoraan - No comment provided by engineer. - Connect incognito Yhdistä Incognito No comment provided by engineer. - - Connect via contact link - Yhdistä kontaktilinkillä + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Yhdistetäänkö ryhmälinkin kautta? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1119,6 +1179,10 @@ Yhdistä kertalinkillä No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Yhteyden muodostaminen palvelimeen… @@ -1164,11 +1228,6 @@ Kontakti on jo olemassa No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Kontakti ja kaikki viestit poistetaan - tätä ei voi perua! - No comment provided by engineer. - Contact hidden: Kontakti piilotettu: @@ -1219,6 +1278,10 @@ Ydinversio: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Luo @@ -1239,6 +1302,10 @@ Luo tiedosto server test step + + Create group + No comment provided by engineer. + Create group link Luo ryhmälinkki @@ -1251,6 +1318,7 @@ Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + Luo uusi profiili [työpöytäsovelluksessa](https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -1258,6 +1326,10 @@ Luo kertakutsulinkki No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Luo jono @@ -1416,6 +1488,10 @@ Poista chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Poista kontakti @@ -1441,6 +1517,10 @@ Poista kaikki tiedostot No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Poista arkisto @@ -1471,9 +1551,9 @@ Poista kontakti No comment provided by engineer. - - Delete contact? - Poista kontakti? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1708,16 +1788,7 @@ Discover and join groups - No comment provided by engineer. - - - Display name - Näyttönimi - No comment provided by engineer. - - - Display name: - Näyttönimi: + Löydä ryhmiä ja liity niihin No comment provided by engineer. @@ -1899,6 +1970,10 @@ Anna oikea tunnuslause. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Syötä tunnuslause… @@ -1924,6 +1999,10 @@ Kirjoita tervetuloviesti... (valinnainen) placeholder + + Enter your name… + No comment provided by engineer. + Error Virhe @@ -2197,6 +2276,10 @@ Poistu tallentamatta No comment provided by engineer. + + Expand + chat item action + Export database Vie tietokanta @@ -2342,6 +2425,10 @@ Koko nimi: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Täysin uudistettu - toimii taustalla! @@ -2362,6 +2449,14 @@ Ryhmä No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Ryhmän näyttönimi @@ -2709,6 +2804,10 @@ Virheellinen yhteyslinkki No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Virheellinen palvelinosoite! @@ -2800,11 +2899,24 @@ Liity ryhmään No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Liity incognito-tilassa No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Liittyy ryhmään @@ -3020,6 +3132,10 @@ Viestit ja tiedostot No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Siirretään tietokannan arkistoa… @@ -3367,6 +3483,10 @@ Avaa keskustelukonsoli authentication reason + + Open group + No comment provided by engineer. + Open user profiles Avaa käyttäjäprofiilit @@ -3382,11 +3502,6 @@ Avataan tietokantaa… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Linkin avaaminen selaimessa voi heikentää yhteyden yksityisyyttä ja turvallisuutta. Epäluotetut SimpleX-linkit näkyvät punaisina. - No comment provided by engineer. - PING count PING-määrä @@ -3577,6 +3692,14 @@ Profiilikuva No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Profiilin salasana @@ -3822,6 +3945,14 @@ Uudelleenneuvottele salaus? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Vastaa @@ -4535,6 +4666,10 @@ Napauta painiketta No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Aktivoi profiili napauttamalla. @@ -4632,11 +4767,6 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Ryhmä on täysin hajautettu - se näkyy vain jäsenille. - No comment provided by engineer. - The hash of the previous message is different. Edellisen viestin tarkiste on erilainen. @@ -4732,6 +4862,14 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Tätä ryhmää ei enää ole olemassa. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**. @@ -4828,6 +4966,18 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Ääniviestiä ei voi tallentaa No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Odottamaton virhe: %@ @@ -5175,6 +5325,35 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Olet jo muodostanut yhteyden %@:n kanssa. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta. @@ -5270,6 +5449,15 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Sinua ei voitu todentaa; yritä uudelleen. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Sinulla ei ole keskusteluja @@ -5320,6 +5508,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Sinut yhdistetään ryhmään, kun ryhmän isännän laite on online-tilassa, odota tai tarkista myöhemmin! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Sinut yhdistetään, kun yhteyspyyntösi on hyväksytty, odota tai tarkista myöhemmin! @@ -5335,9 +5527,8 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Sinun on tunnistauduttava, kun käynnistät sovelluksen tai jatkat sen käyttöä 30 sekunnin tauon jälkeen. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Liityt ryhmään, johon tämä linkki viittaa, ja muodostat yhteyden sen ryhmän jäseniin. + + You will connect to all group members. No comment provided by engineer. @@ -5405,11 +5596,6 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Keskustelut-tietokantasi ei ole salattu - aseta tunnuslause sen salaamiseksi. No comment provided by engineer. - - Your chat profile will be sent to group members - Keskusteluprofiilisi lähetetään ryhmän jäsenille - No comment provided by engineer. - Your chat profiles Keskusteluprofiilisi @@ -5464,6 +5650,10 @@ Voit muuttaa sitä Asetuksista. Yksityisyytesi No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Profiilisi **%@** jaetaan. @@ -5556,6 +5746,10 @@ SimpleX-palvelimet eivät näe profiiliasi. aina pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) äänipuhelu (ei e2e-salattu) @@ -5571,6 +5765,10 @@ SimpleX-palvelimet eivät näe profiiliasi. virheellinen viestin tarkiste integrity error chat item + + blocked + No comment provided by engineer. + bold lihavoitu @@ -5740,6 +5938,10 @@ SimpleX-palvelimet eivät näe profiiliasi. poistettu deleted chat item + + deleted contact + rcv direct event chat item + deleted group poistettu ryhmä @@ -6024,7 +6226,8 @@ SimpleX-palvelimet eivät näe profiiliasi. off pois enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6041,11 +6244,6 @@ SimpleX-palvelimet eivät näe profiiliasi. päällä group pref value - - or chat with the developers - tai keskustele kehittäjien kanssa - No comment provided by engineer. - owner omistaja diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 78f7fca921..381a50fe8f 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ et %@ sont connecté.es @@ -97,6 +101,10 @@ %1$@ à %2$@ : copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ est connecté·e ! @@ -122,6 +130,10 @@ %@ veut se connecter ! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ et %lld autres membres sont connectés @@ -187,11 +199,27 @@ %lld fichier·s pour une taille totale de %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld membres No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minutes @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -589,6 +621,10 @@ Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Tous vos contacts resteront connectés. @@ -694,6 +730,14 @@ Déjà connecté ? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Se connecter via relais @@ -829,6 +873,18 @@ Meilleurs messages No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Vous et votre contact pouvez ajouter des réactions aux messages. @@ -1090,24 +1146,27 @@ Se connecter server test step - - Connect directly - Se connecter directement - No comment provided by engineer. - Connect incognito Se connecter incognito No comment provided by engineer. - - Connect via contact link - Se connecter via un lien de contact + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Se connecter via le lien du groupe ? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Se connecter via un lien unique No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Connexion au serveur… @@ -1170,11 +1233,6 @@ Contact déjà existant No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Le contact et tous les messages seront supprimés - impossible de revenir en arrière ! - No comment provided by engineer. - Contact hidden: Contact masqué : @@ -1225,6 +1283,10 @@ Version du cœur : v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Créer @@ -1245,6 +1307,10 @@ Créer un fichier server test step + + Create group + No comment provided by engineer. + Create group link Créer un lien de groupe @@ -1265,6 +1331,10 @@ Créer un lien d'invitation unique No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Créer une file d'attente @@ -1423,6 +1493,10 @@ Supprimer chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Supprimer le contact @@ -1448,6 +1522,10 @@ Effacer tous les fichiers No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Supprimer l'archive @@ -1478,9 +1556,9 @@ Supprimer le contact No comment provided by engineer. - - Delete contact? - Supprimer le contact ? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Découvrir et rejoindre des groupes No comment provided by engineer. - - Display name - Nom affiché - No comment provided by engineer. - - - Display name: - Nom affiché : - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. N'utilisez PAS SimpleX pour les appels d'urgence. @@ -1908,6 +1976,10 @@ Entrez la phrase secrète correcte. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Entrez la phrase secrète… @@ -1933,6 +2005,10 @@ Entrez un message de bienvenue… (facultatif) placeholder + + Enter your name… + No comment provided by engineer. + Error Erreur @@ -1990,6 +2066,7 @@ Error creating member contact + Erreur lors de la création du contact du membre No comment provided by engineer. @@ -2124,6 +2201,7 @@ Error sending member contact invitation + Erreur lors de l'envoi de l'invitation de contact d'un membre No comment provided by engineer. @@ -2206,6 +2284,10 @@ Quitter sans sauvegarder No comment provided by engineer. + + Expand + chat item action + Export database Exporter la base de données @@ -2351,6 +2433,10 @@ Nom complet : No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Entièrement réimplémenté - fonctionne en arrière-plan ! @@ -2371,6 +2457,14 @@ Groupe No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Nom d'affichage du groupe @@ -2718,6 +2812,10 @@ Lien de connection invalide No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Adresse de serveur invalide ! @@ -2809,11 +2907,24 @@ Rejoindre le groupe No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Rejoindre en incognito No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Entrain de rejoindre le groupe @@ -3029,6 +3140,10 @@ Messages No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Migration de l'archive de la base de données… @@ -3360,6 +3475,7 @@ Open + Ouvrir No comment provided by engineer. @@ -3377,6 +3493,10 @@ Ouvrir la console du chat authentication reason + + Open group + No comment provided by engineer. + Open user profiles Ouvrir les profils d'utilisateurs @@ -3392,11 +3512,6 @@ Ouverture de la base de données… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge. - No comment provided by engineer. - PING count Nombre de PING @@ -3587,6 +3702,14 @@ Image de profil No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Mot de passe de profil @@ -3832,6 +3955,14 @@ Renégocier le chiffrement? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Répondre @@ -4094,11 +4225,12 @@ Send direct message - Envoi de message direct + Envoyer un message direct No comment provided by engineer. Send direct message to connect + Envoyer un message direct pour vous connecter No comment provided by engineer. @@ -4546,6 +4678,10 @@ Appuyez sur le bouton No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Appuyez pour activer un profil. @@ -4643,11 +4779,6 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Le chiffrement fonctionne et le nouvel accord de chiffrement n'est pas nécessaire. Cela peut provoquer des erreurs de connexion ! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Le groupe est entièrement décentralisé – il n'est visible que par ses membres. - No comment provided by engineer. - The hash of the previous message is different. Le hash du message précédent est différent. @@ -4743,6 +4874,14 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Ce groupe n'existe plus. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Ce paramètre s'applique aux messages de votre profil de chat actuel **%@**. @@ -4840,6 +4979,18 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Impossible d'enregistrer un message vocal No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Erreur inattendue : %@ @@ -5187,6 +5338,35 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Vous êtes déjà connecté·e à %@ via ce lien. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact. @@ -5282,6 +5462,15 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Vous n'avez pas pu être vérifié·e ; veuillez réessayer. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Vous n'avez aucune discussion @@ -5332,6 +5521,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Vous serez connecté·e au groupe lorsque l'appareil de l'hôte sera en ligne, veuillez attendre ou vérifier plus tard ! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard ! @@ -5347,9 +5540,8 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l'application après 30 secondes en arrière-plan. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5609,6 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Votre base de données de chat n'est pas chiffrée - définisez une phrase secrète. No comment provided by engineer. - - Your chat profile will be sent to group members - Votre profil de chat sera envoyé aux membres du groupe - No comment provided by engineer. - Your chat profiles Vos profils de chat @@ -5476,6 +5663,10 @@ Vous pouvez modifier ce choix dans les Paramètres. Votre vie privée No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Votre profil **%@** sera partagé. @@ -5568,6 +5759,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. toujours pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) appel audio (sans chiffrement) @@ -5583,6 +5778,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. hash de message incorrect integrity error chat item + + blocked + No comment provided by engineer. + bold gras @@ -5655,6 +5854,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. connected directly + s'est connecté.e de manière directe rcv group event chat item @@ -5752,6 +5952,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. supprimé deleted chat item + + deleted contact + rcv direct event chat item + deleted group groupe supprimé @@ -6036,7 +6240,8 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. off off enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6258,6 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. on group pref value - - or chat with the developers - ou ici pour discuter avec les développeurs - No comment provided by engineer. - owner propriétaire @@ -6120,6 +6320,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. send direct message + envoyer un message direct No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 2e9b9a3264..1faabca974 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ e %@ sono connessi/e @@ -97,6 +101,10 @@ %1$@ alle %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ è connesso/a! @@ -122,6 +130,10 @@ %@ si vuole connettere! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ e altri %lld membri sono connessi @@ -187,11 +199,27 @@ %lld file con dimensione totale di %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld membri No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minuti @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -589,6 +621,10 @@ Tutti i messaggi verranno eliminati, non è reversibile! I messaggi verranno eliminati SOLO per te. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Tutti i tuoi contatti resteranno connessi. @@ -694,6 +730,14 @@ Già connesso/a? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Connetti via relay @@ -829,6 +873,18 @@ Messaggi migliorati No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Sia tu che il tuo contatto potete aggiungere reazioni ai messaggi. @@ -1090,24 +1146,27 @@ Connetti server test step - - Connect directly - Connetti direttamente - No comment provided by engineer. - Connect incognito Connetti in incognito No comment provided by engineer. - - Connect via contact link - Connetti via link del contatto + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Connettere via link del gruppo? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Connetti via link una tantum No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Connessione al server… @@ -1170,11 +1233,6 @@ Il contatto esiste già No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Il contatto e tutti i messaggi verranno eliminati, non è reversibile! - No comment provided by engineer. - Contact hidden: Contatto nascosto: @@ -1225,6 +1283,10 @@ Versione core: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Crea @@ -1245,6 +1307,10 @@ Crea file server test step + + Create group + No comment provided by engineer. + Create group link Crea link del gruppo @@ -1265,6 +1331,10 @@ Crea link di invito una tantum No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Crea coda @@ -1423,6 +1493,10 @@ Elimina chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Elimina contatto @@ -1448,6 +1522,10 @@ Elimina tutti i file No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Elimina archivio @@ -1478,9 +1556,9 @@ Elimina contatto No comment provided by engineer. - - Delete contact? - Eliminare il contatto? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Scopri ed unisciti ai gruppi No comment provided by engineer. - - Display name - Nome da mostrare - No comment provided by engineer. - - - Display name: - Nome da mostrare: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. NON usare SimpleX per chiamate di emergenza. @@ -1908,6 +1976,10 @@ Inserisci la password giusta. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Inserisci la password… @@ -1933,6 +2005,10 @@ Inserisci il messaggio di benvenuto… (facoltativo) placeholder + + Enter your name… + No comment provided by engineer. + Error Errore @@ -1990,6 +2066,7 @@ Error creating member contact + Errore di creazione del contatto No comment provided by engineer. @@ -2124,6 +2201,7 @@ Error sending member contact invitation + Errore di invio dell'invito al contatto No comment provided by engineer. @@ -2206,6 +2284,10 @@ Esci senza salvare No comment provided by engineer. + + Expand + chat item action + Export database Esporta database @@ -2351,6 +2433,10 @@ Nome completo: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Completamente reimplementato - funziona in secondo piano! @@ -2371,6 +2457,14 @@ Gruppo No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Nome mostrato del gruppo @@ -2718,6 +2812,10 @@ Link di connessione non valido No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Indirizzo del server non valido! @@ -2809,11 +2907,24 @@ Entra nel gruppo No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Entra in incognito No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Ingresso nel gruppo @@ -3029,6 +3140,10 @@ Messaggi No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Migrazione archivio del database… @@ -3360,6 +3475,7 @@ Open + Apri No comment provided by engineer. @@ -3377,6 +3493,10 @@ Apri la console della chat authentication reason + + Open group + No comment provided by engineer. + Open user profiles Apri i profili utente @@ -3392,11 +3512,6 @@ Apertura del database… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Aprire il link nel browser può ridurre la privacy e la sicurezza della connessione. I link SimpleX non fidati saranno in rosso. - No comment provided by engineer. - PING count Conteggio PING @@ -3587,6 +3702,14 @@ Immagine del profilo No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Password del profilo @@ -3832,6 +3955,14 @@ Rinegoziare la crittografia? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Rispondi @@ -3894,7 +4025,7 @@ Revert - Annulla + Ripristina No comment provided by engineer. @@ -4099,6 +4230,7 @@ Send direct message to connect + Invia messaggio diretto per connetterti No comment provided by engineer. @@ -4546,6 +4678,10 @@ Tocca il pulsante No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Tocca per attivare il profilo. @@ -4643,11 +4779,6 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.La crittografia funziona e il nuovo accordo sulla crittografia non è richiesto. Potrebbero verificarsi errori di connessione! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Il gruppo è completamente decentralizzato: è visibile solo ai membri. - No comment provided by engineer. - The hash of the previous message is different. L'hash del messaggio precedente è diverso. @@ -4743,6 +4874,14 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questo gruppo non esiste più. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Questa impostazione si applica ai messaggi del profilo di chat attuale **%@**. @@ -4840,6 +4979,18 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Impossibile registrare il messaggio vocale No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Errore imprevisto: % @ @@ -5187,6 +5338,35 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Sei già connesso/a a %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Sei connesso/a al server usato per ricevere messaggi da questo contatto. @@ -5282,6 +5462,15 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Non è stato possibile verificarti, riprova. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Non hai chat @@ -5332,6 +5521,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Verrai connesso/a al gruppo quando il dispositivo dell'host del gruppo sarà in linea, attendi o controlla più tardi! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Verrai connesso/a quando la tua richiesta di connessione verrà accettata, attendi o controlla più tardi! @@ -5347,9 +5540,8 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Dovrai autenticarti quando avvii o riapri l'app dopo 30 secondi in secondo piano. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Entrerai in un gruppo a cui si riferisce questo link e ti connetterai ai suoi membri. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5609,6 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Il tuo database della chat non è crittografato: imposta la password per crittografarlo. No comment provided by engineer. - - Your chat profile will be sent to group members - Il tuo profilo di chat verrà inviato ai membri del gruppo - No comment provided by engineer. - Your chat profiles I tuoi profili di chat @@ -5476,6 +5663,10 @@ Puoi modificarlo nelle impostazioni. La tua privacy No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Il tuo profilo **%@** verrà condiviso. @@ -5568,6 +5759,10 @@ I server di SimpleX non possono vedere il tuo profilo. sempre pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) chiamata audio (non crittografata e2e) @@ -5583,6 +5778,10 @@ I server di SimpleX non possono vedere il tuo profilo. hash del messaggio errato integrity error chat item + + blocked + No comment provided by engineer. + bold grassetto @@ -5655,6 +5854,7 @@ I server di SimpleX non possono vedere il tuo profilo. connected directly + si è connesso/a direttamente rcv group event chat item @@ -5752,6 +5952,10 @@ I server di SimpleX non possono vedere il tuo profilo. eliminato deleted chat item + + deleted contact + rcv direct event chat item + deleted group gruppo eliminato @@ -5969,7 +6173,7 @@ I server di SimpleX non possono vedere il tuo profilo. connected - è connesso/a + si è connesso/a rcv group event chat item @@ -6036,7 +6240,8 @@ I server di SimpleX non possono vedere il tuo profilo. off off enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6258,6 @@ I server di SimpleX non possono vedere il tuo profilo. on group pref value - - or chat with the developers - o scrivi agli sviluppatori - No comment provided by engineer. - owner proprietario @@ -6085,7 +6285,7 @@ I server di SimpleX non possono vedere il tuo profilo. removed - ha rimosso + rimosso No comment provided by engineer. @@ -6095,7 +6295,7 @@ I server di SimpleX non possono vedere il tuo profilo. removed you - sei stato/a rimosso/a + ti ha rimosso/a rcv group event chat item @@ -6120,6 +6320,7 @@ I server di SimpleX non possono vedere il tuo profilo. send direct message + invia messaggio diretto No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 27d7cb54f6..9f688d1ff5 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ と %@ は接続中 @@ -97,6 +101,10 @@ %1$@ at %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ 接続中! @@ -122,6 +130,10 @@ %@ が接続を希望しています! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ および %lld 人のメンバーが接続中 @@ -187,11 +199,27 @@ %lld 個のファイル(合計サイズ: %@) No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld 人のメンバー No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld 分 @@ -199,6 +227,7 @@ %lld new interface languages + %lldつの新しいインターフェース言語 No comment provided by engineer. @@ -360,6 +389,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0秒 @@ -585,6 +618,10 @@ 全てのメッセージが削除されます(※注意:元に戻せません!※)。削除されるのは片方あなたのメッセージのみ。 No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. あなたの連絡先が繋がったまま継続します。 @@ -690,6 +727,14 @@ すでに接続済みですか? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay 常にリレーを経由する @@ -712,6 +757,7 @@ App encrypts new local files (except videos). + アプリは新しいローカルファイル(ビデオを除く)を暗号化します。 No comment provided by engineer. @@ -824,6 +870,18 @@ より良いメッセージ No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. 自分も相手もメッセージへのリアクションを追加できます。 @@ -851,6 +909,7 @@ Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + ブルガリア語、フィンランド語、タイ語、ウクライナ語 - ユーザーと [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)に感謝します! No comment provided by engineer. @@ -1084,24 +1143,27 @@ 接続 server test step - - Connect directly - 直接接続する - No comment provided by engineer. - Connect incognito シークレットモードで接続 No comment provided by engineer. - - Connect via contact link - 連絡先リンク経由で接続しますか? + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - グループリンク経由で接続しますか? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1119,6 +1181,10 @@ 使い捨てリンク経由で接続しますか? No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… サーバーに接続中… @@ -1164,11 +1230,6 @@ 連絡先に既に存在します No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - 連絡先と全メッセージが削除されます (※元に戻せません※)! - No comment provided by engineer. - Contact hidden: 連絡先が非表示: @@ -1219,6 +1280,10 @@ コアのバージョン: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create 作成 @@ -1239,6 +1304,10 @@ ファイルを作成 server test step + + Create group + No comment provided by engineer. + Create group link グループのリンクを生成する @@ -1251,6 +1320,7 @@ Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + [デスクトップアプリ](https://simplex.chat/downloads/)で新しいプロファイルを作成します。 💻 No comment provided by engineer. @@ -1258,6 +1328,10 @@ 使い捨ての招待リンクを生成する No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue キューの作成 @@ -1416,6 +1490,10 @@ 削除 chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact 連絡先を削除 @@ -1441,6 +1519,10 @@ ファイルを全て削除 No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive アーカイブを削除 @@ -1471,9 +1553,9 @@ 連絡先を削除 No comment provided by engineer. - - Delete contact? - 連絡先を削除しますか? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1608,6 +1690,7 @@ Delivery receipts! + 配信通知! No comment provided by engineer. @@ -1707,16 +1790,7 @@ Discover and join groups - No comment provided by engineer. - - - Display name - 表示名 - No comment provided by engineer. - - - Display name: - 表示名: + グループを見つけて参加する No comment provided by engineer. @@ -1851,6 +1925,7 @@ Encrypt stored files & media + 保存されたファイルとメディアを暗号化する No comment provided by engineer. @@ -1898,6 +1973,10 @@ 正しいパスフレーズを入力してください。 No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… 暗証フレーズを入力… @@ -1923,6 +2002,10 @@ ウェルカムメッセージを入力…(オプション) placeholder + + Enter your name… + No comment provided by engineer. + Error エラー @@ -1980,6 +2063,7 @@ Error creating member contact + メンバー連絡先の作成中にエラーが発生 No comment provided by engineer. @@ -2113,6 +2197,7 @@ Error sending member contact invitation + 招待メッセージの送信エラー No comment provided by engineer. @@ -2194,6 +2279,10 @@ 保存せずに閉じる No comment provided by engineer. + + Expand + chat item action + Export database データベースをエキスポート @@ -2339,6 +2428,10 @@ フルネーム: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! 完全に再実装されました - バックグラウンドで動作します! @@ -2359,6 +2452,14 @@ グループ No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name グループ表示の名前 @@ -2706,6 +2807,10 @@ 無効な接続リンク No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! 無効なサーバアドレス! @@ -2797,11 +2902,24 @@ グループに参加 No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito シークレットモードで参加 No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group グループに参加 @@ -3016,6 +3134,10 @@ メッセージ & ファイル No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… データベースのアーカイブを移行しています… @@ -3128,6 +3250,7 @@ New desktop app! + 新しいデスクトップアプリ! No comment provided by engineer. @@ -3346,6 +3469,7 @@ Open + 開く No comment provided by engineer. @@ -3363,6 +3487,10 @@ チャットのコンソールを開く authentication reason + + Open group + No comment provided by engineer. + Open user profiles ユーザープロフィールを開く @@ -3378,11 +3506,6 @@ データベースを開いています… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - ブラウザでリンクを開くと接続のプライバシーとセキュリティが下がる可能性があります。信頼されないSimpleXリンクは読み込まれません。 - No comment provided by engineer. - PING count PING回数 @@ -3573,6 +3696,14 @@ プロフィール画像 No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password プロフィールのパスワード @@ -3817,6 +3948,14 @@ 暗号化を再ネゴシエートしますか? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply 返信 @@ -4083,6 +4222,7 @@ Send direct message to connect + ダイレクトメッセージを送信して接続する No comment provided by engineer. @@ -4380,6 +4520,7 @@ Simplified incognito mode + シークレットモードの簡素化 No comment provided by engineer. @@ -4522,6 +4663,10 @@ ボタンをタップ No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. タップしてプロフィールを有効化する。 @@ -4619,11 +4764,6 @@ It can happen because of some bug or when the connection is compromised.暗号化は機能しており、新しい暗号化への同意は必要ありません。接続エラーが発生する可能性があります! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - グループは完全分散型で、メンバーしか内容を見れません。 - No comment provided by engineer. - The hash of the previous message is different. 以前のメッセージとハッシュ値が異なります。 @@ -4718,6 +4858,14 @@ It can happen because of some bug or when the connection is compromised.このグループはもう存在しません。 No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. この設定は現在のチャットプロフィール **%@** のメッセージに適用されます。 @@ -4814,6 +4962,18 @@ You will be prompted to complete authentication before this feature is enabled.< 音声メッセージを録音できません No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ 予期しないエラー: %@ @@ -5161,6 +5321,35 @@ To connect, please ask your contact to create another connection link and check すでに %@ に接続されています。 No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. この連絡先から受信するメッセージのサーバに既に接続してます。 @@ -5256,6 +5445,15 @@ To connect, please ask your contact to create another connection link and check 確認できませんでした。 もう一度お試しください。 No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats あなたはチャットがありません @@ -5306,6 +5504,10 @@ To connect, please ask your contact to create another connection link and check グループのホスト端末がオンラインになったら、接続されます。後でチェックするか、しばらくお待ちください! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! 連絡先が繋がりリクエストを承認したら、接続されます。後でチェックするか、しばらくお待ちください! @@ -5321,9 +5523,8 @@ To connect, please ask your contact to create another connection link and check 起動時、または非アクティブ状態で30秒が経った後に戻ると、認証する必要となります。 No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - このリンクのグループに参加し、そのメンバーに繋がります。 + + You will connect to all group members. No comment provided by engineer. @@ -5391,11 +5592,6 @@ To connect, please ask your contact to create another connection link and check チャット データベースは暗号化されていません - 暗号化するにはパスフレーズを設定してください。 No comment provided by engineer. - - Your chat profile will be sent to group members - あなたのチャットプロフィールが他のグループメンバーに送られます - No comment provided by engineer. - Your chat profiles あなたのチャットプロフィール @@ -5450,6 +5646,10 @@ You can change it in Settings. あなたのプライバシー No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. あなたのプロファイル **%@** が共有されます。 @@ -5542,6 +5742,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 常に pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) 音声通話 (エンドツーエンド暗号化なし) @@ -5557,6 +5761,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 メッセージのハッシュ値問題 integrity error chat item + + blocked + No comment provided by engineer. + bold 太文字 @@ -5726,6 +5934,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 削除完了 deleted chat item + + deleted contact + rcv direct event chat item + deleted group 削除されたグループ @@ -6010,7 +6222,8 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 off オフ enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6027,11 +6240,6 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 オン group pref value - - or chat with the developers - または開発者とチャットする - No comment provided by engineer. - owner オーナー diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 233b1d0ba1..257b2ff0ce 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ en %@ verbonden @@ -97,6 +101,10 @@ %1$@ bij %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ is verbonden! @@ -122,6 +130,10 @@ %@ wil verbinding maken! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ en %lld andere leden hebben verbinding gemaakt @@ -187,11 +199,27 @@ %lld bestand(en) met een totale grootte van %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld leden No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minuten @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -589,6 +621,10 @@ Alle berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! De berichten worden ALLEEN voor jou verwijderd. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Al uw contacten blijven verbonden. @@ -694,6 +730,14 @@ Al verbonden? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Verbinden via relais @@ -829,6 +873,18 @@ Betere berichten No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Zowel u als uw contact kunnen berichtreacties toevoegen. @@ -1090,24 +1146,27 @@ Verbind server test step - - Connect directly - Verbind direct - No comment provided by engineer. - Connect incognito Verbind incognito No comment provided by engineer. - - Connect via contact link - Verbinden via contact link? + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Verbinden via groep link? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Verbinden via een eenmalige link? No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Verbinden met de server… @@ -1170,11 +1233,6 @@ Contact bestaat al No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Contact en alle berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! - No comment provided by engineer. - Contact hidden: Contact verborgen: @@ -1225,6 +1283,10 @@ Core versie: v% @ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Maak @@ -1245,6 +1307,10 @@ Bestand maken server test step + + Create group + No comment provided by engineer. + Create group link Groep link maken @@ -1265,6 +1331,10 @@ Maak een eenmalige uitnodiging link No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Maak een wachtrij @@ -1423,6 +1493,10 @@ Verwijderen chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Verwijder contact @@ -1448,6 +1522,10 @@ Verwijder alle bestanden No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Archief verwijderen @@ -1478,9 +1556,9 @@ Verwijder contact No comment provided by engineer. - - Delete contact? - Verwijder contact? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Ontdek en sluit je aan bij groepen No comment provided by engineer. - - Display name - Weergavenaam - No comment provided by engineer. - - - Display name: - Weergavenaam: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. Gebruik SimpleX NIET voor noodoproepen. @@ -1908,6 +1976,10 @@ Voer het juiste wachtwoord in. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Voer wachtwoord in… @@ -1933,6 +2005,10 @@ Voer welkomst bericht in... (optioneel) placeholder + + Enter your name… + No comment provided by engineer. + Error Fout @@ -1990,6 +2066,7 @@ Error creating member contact + Fout bij aanmaken contact No comment provided by engineer. @@ -2124,6 +2201,7 @@ Error sending member contact invitation + Fout bij verzenden van contact uitnodiging No comment provided by engineer. @@ -2206,6 +2284,10 @@ Afsluiten zonder opslaan No comment provided by engineer. + + Expand + chat item action + Export database Database exporteren @@ -2351,6 +2433,10 @@ Volledige naam: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Volledig opnieuw geïmplementeerd - werk op de achtergrond! @@ -2371,6 +2457,14 @@ Groep No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Weergave naam groep @@ -2718,6 +2812,10 @@ Ongeldige verbinding link No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Ongeldig server adres! @@ -2770,7 +2868,7 @@ It can happen when you or your connection used the old database backup. - Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt. + Het kan gebeuren wanneer u of de ander een oude database back-up gebruikt. No comment provided by engineer. @@ -2780,7 +2878,7 @@ 3. The connection was compromised. Het kan gebeuren wanneer: 1. De berichten zijn na 2 dagen verlopen bij de verzendende client of na 30 dagen op de server. -2. Decodering van het bericht is mislukt, omdat u of uw contact een oude databaseback-up heeft gebruikt. +2. Decodering van het bericht is mislukt, omdat u of uw contact een oude database back-up heeft gebruikt. 3. De verbinding is verbroken. No comment provided by engineer. @@ -2809,11 +2907,24 @@ Word lid van groep No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Doe incognito mee No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Deel nemen aan groep @@ -3029,6 +3140,10 @@ Berichten No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Database archief migreren… @@ -3360,6 +3475,7 @@ Open + Open No comment provided by engineer. @@ -3377,6 +3493,10 @@ Chat console openen authentication reason + + Open group + No comment provided by engineer. + Open user profiles Gebruikers profielen openen @@ -3392,11 +3512,6 @@ Database openen… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Het openen van de link in de browser kan de privacy en beveiliging van de verbinding verminderen. Niet vertrouwde SimpleX links worden rood weergegeven. - No comment provided by engineer. - PING count PING count @@ -3587,6 +3702,14 @@ profielfoto No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Profiel wachtwoord @@ -3832,6 +3955,14 @@ Heronderhandelen over versleuteling? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Antwoord @@ -4099,6 +4230,7 @@ Send direct message to connect + Stuur een direct bericht om verbinding te maken No comment provided by engineer. @@ -4546,6 +4678,10 @@ Tik op de knop No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Tik om profiel te activeren. @@ -4643,11 +4779,6 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. De versleuteling werkt en de nieuwe versleutelingsovereenkomst is niet vereist. Dit kan leiden tot verbindingsfouten! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - De groep is volledig gedecentraliseerd – het is alleen zichtbaar voor de leden. - No comment provided by engineer. - The hash of the previous message is different. De hash van het vorige bericht is anders. @@ -4743,6 +4874,14 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Deze groep bestaat niet meer. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Deze instelling is van toepassing op berichten in je huidige chat profiel **%@**. @@ -4840,6 +4979,18 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Kan spraakbericht niet opnemen No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Onverwachte fout: %@ @@ -5187,6 +5338,35 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak U bent al verbonden met %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. @@ -5282,6 +5462,15 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak U kon niet worden geverifieerd; probeer het opnieuw. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Je hebt geen gesprekken @@ -5332,6 +5521,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Je wordt verbonden met de groep wanneer het apparaat van de groep host online is, even geduld a.u.b. of controleer het later! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! U wordt verbonden wanneer uw verbindingsverzoek wordt geaccepteerd, even geduld a.u.b. of controleer later! @@ -5347,9 +5540,8 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak U moet zich authenticeren wanneer u de app na 30 seconden op de achtergrond start of hervat. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - U wordt lid van de groep waar deze link naar verwijst en maakt verbinding met de groepsleden. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5609,6 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Uw chat database is niet versleuteld, stel een wachtwoord in om deze te versleutelen. No comment provided by engineer. - - Your chat profile will be sent to group members - Uw chat profiel wordt verzonden naar de groepsleden - No comment provided by engineer. - Your chat profiles Uw chat profielen @@ -5476,6 +5663,10 @@ U kunt dit wijzigen in Instellingen. Uw privacy No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Uw profiel **%@** wordt gedeeld. @@ -5568,6 +5759,10 @@ SimpleX servers kunnen uw profiel niet zien. altijd pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) audio oproep (niet e2e versleuteld) @@ -5583,6 +5778,10 @@ SimpleX servers kunnen uw profiel niet zien. Onjuiste bericht hash integrity error chat item + + blocked + No comment provided by engineer. + bold vetgedrukt @@ -5655,6 +5854,7 @@ SimpleX servers kunnen uw profiel niet zien. connected directly + direct verbonden rcv group event chat item @@ -5752,6 +5952,10 @@ SimpleX servers kunnen uw profiel niet zien. verwijderd deleted chat item + + deleted contact + rcv direct event chat item + deleted group verwijderde groep @@ -6036,7 +6240,8 @@ SimpleX servers kunnen uw profiel niet zien. off uit enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6258,6 @@ SimpleX servers kunnen uw profiel niet zien. aan group pref value - - or chat with the developers - of praat met de ontwikkelaars - No comment provided by engineer. - owner Eigenaar @@ -6120,6 +6320,7 @@ SimpleX servers kunnen uw profiel niet zien. send direct message + stuur een direct bericht No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 2e0e2de446..03ac0250a0 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ i %@ połączeni @@ -97,6 +101,10 @@ %1$@ o %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ jest połączony! @@ -122,6 +130,10 @@ %@ chce się połączyć! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ i %lld innych członków połączeni @@ -187,11 +199,27 @@ %lld plik(i) o całkowitym rozmiarze %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld członków No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld minut @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -589,6 +621,10 @@ Wszystkie wiadomości zostaną usunięte - nie można tego cofnąć! Wiadomości zostaną usunięte TYLKO dla Ciebie. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Wszystkie Twoje kontakty pozostaną połączone. @@ -694,6 +730,14 @@ Już połączony? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Zawsze używaj przekaźnika @@ -829,6 +873,18 @@ Lepsze wiadomości No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Zarówno Ty, jak i Twój kontakt możecie dodawać reakcje wiadomości. @@ -1090,24 +1146,27 @@ Połącz server test step - - Connect directly - Połącz bezpośrednio - No comment provided by engineer. - Connect incognito Połącz incognito No comment provided by engineer. - - Connect via contact link - Połącz przez link kontaktowy + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Połącz się przez link grupowy? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Połącz przez jednorazowy link No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Łączenie z serwerem… @@ -1170,11 +1233,6 @@ Kontakt już istnieje No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Kontakt i wszystkie wiadomości zostaną usunięte - nie można tego cofnąć! - No comment provided by engineer. - Contact hidden: Kontakt ukryty: @@ -1225,6 +1283,10 @@ Wersja rdzenia: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Utwórz @@ -1245,6 +1307,10 @@ Utwórz plik server test step + + Create group + No comment provided by engineer. + Create group link Utwórz link do grupy @@ -1265,6 +1331,10 @@ Utwórz jednorazowy link do zaproszenia No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Utwórz kolejkę @@ -1423,6 +1493,10 @@ Usuń chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Usuń Kontakt @@ -1448,6 +1522,10 @@ Usuń wszystkie pliki No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Usuń archiwum @@ -1478,9 +1556,9 @@ Usuń kontakt No comment provided by engineer. - - Delete contact? - Usunąć kontakt? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Odkrywaj i dołączaj do grup No comment provided by engineer. - - Display name - Wyświetlana nazwa - No comment provided by engineer. - - - Display name: - Wyświetlana nazwa: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. NIE używaj SimpleX do połączeń alarmowych. @@ -1908,6 +1976,10 @@ Wprowadź poprawne hasło. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Wprowadź hasło… @@ -1933,6 +2005,10 @@ Wpisz wiadomość powitalną… (opcjonalne) placeholder + + Enter your name… + No comment provided by engineer. + Error Błąd @@ -1990,6 +2066,7 @@ Error creating member contact + Błąd tworzenia kontaktu członka No comment provided by engineer. @@ -2124,6 +2201,7 @@ Error sending member contact invitation + Błąd wysyłania zaproszenia kontaktu członka No comment provided by engineer. @@ -2206,6 +2284,10 @@ Wyjdź bez zapisywania No comment provided by engineer. + + Expand + chat item action + Export database Eksportuj bazę danych @@ -2351,6 +2433,10 @@ Pełna nazwa: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! W pełni ponownie zaimplementowany - praca w tle! @@ -2371,6 +2457,14 @@ Grupa No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Wyświetlana nazwa grupy @@ -2718,6 +2812,10 @@ Nieprawidłowy link połączenia No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Nieprawidłowy adres serwera! @@ -2809,11 +2907,24 @@ Dołącz do grupy No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Dołącz incognito No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Dołączanie do grupy @@ -3029,6 +3140,10 @@ Wiadomości i pliki No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Migrowanie archiwum bazy danych… @@ -3360,6 +3475,7 @@ Open + Otwórz No comment provided by engineer. @@ -3377,6 +3493,10 @@ Otwórz konsolę czatu authentication reason + + Open group + No comment provided by engineer. + Open user profiles Otwórz profile użytkownika @@ -3392,11 +3512,6 @@ Otwieranie bazy danych… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Otwarcie łącza w przeglądarce może zmniejszyć prywatność i bezpieczeństwo połączenia. Niezaufane linki SimpleX będą miały kolor czerwony. - No comment provided by engineer. - PING count Liczba PINGÓW @@ -3587,6 +3702,14 @@ Zdjęcie profilowe No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Hasło profilu @@ -3832,6 +3955,14 @@ Renegocjować szyfrowanie? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Odpowiedz @@ -4099,6 +4230,7 @@ Send direct message to connect + Wyślij wiadomość bezpośrednią aby połączyć No comment provided by engineer. @@ -4546,6 +4678,10 @@ Naciśnij przycisk No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Dotknij, aby aktywować profil. @@ -4643,11 +4779,6 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Szyfrowanie działa, a nowe uzgodnienie szyfrowania nie jest wymagane. Może to spowodować błędy w połączeniu! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Grupa jest w pełni zdecentralizowana – jest widoczna tylko dla członków. - No comment provided by engineer. - The hash of the previous message is different. Hash poprzedniej wiadomości jest inny. @@ -4743,6 +4874,14 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Ta grupa już nie istnieje. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**. @@ -4840,6 +4979,18 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Nie można nagrać wiadomości głosowej No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Nieoczekiwany błąd: %@ @@ -5187,6 +5338,35 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Jesteś już połączony z %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu. @@ -5282,6 +5462,15 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Nie można zweryfikować użytkownika; proszę spróbować ponownie. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats Nie masz czatów @@ -5332,6 +5521,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Zostaniesz połączony do grupy, gdy urządzenie gospodarza grupy będzie online, proszę czekać lub sprawdzić później! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Zostaniesz połączony, gdy Twoje żądanie połączenia zostanie zaakceptowane, proszę czekać lub sprawdzić później! @@ -5347,9 +5540,8 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Uwierzytelnienie będzie wymagane przy uruchamianiu lub wznawianiu aplikacji po 30 sekundach w tle. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Dołączysz do grupy, do której odnosi się ten link i połączysz się z jej członkami. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5609,6 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Baza danych czatu nie jest szyfrowana - ustaw hasło, aby ją zaszyfrować. No comment provided by engineer. - - Your chat profile will be sent to group members - Twój profil czatu zostanie wysłany do członków grupy - No comment provided by engineer. - Your chat profiles Twoje profile czatu @@ -5476,6 +5663,10 @@ Możesz to zmienić w Ustawieniach. Twoja prywatność No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Twój profil **%@** zostanie udostępniony. @@ -5568,6 +5759,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. zawsze pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) połączenie audio (nie szyfrowane e2e) @@ -5583,6 +5778,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. zły hash wiadomości integrity error chat item + + blocked + No comment provided by engineer. + bold pogrubiona @@ -5655,6 +5854,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. connected directly + połącz bezpośrednio rcv group event chat item @@ -5752,6 +5952,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. usunięty deleted chat item + + deleted contact + rcv direct event chat item + deleted group usunięta grupa @@ -6036,7 +6240,8 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. off wyłączony enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6258,6 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. włączone group pref value - - or chat with the developers - lub porozmawiać z deweloperami - No comment provided by engineer. - owner właściciel @@ -6120,6 +6320,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. send direct message + wyślij wiadomość bezpośrednią No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 54841f241e..672a9071c3 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ и %@ соединены @@ -97,6 +101,10 @@ %1$@ в %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! Установлено соединение с %@! @@ -122,6 +130,10 @@ %@ хочет соединиться! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ и %lld других членов соединены @@ -187,11 +199,27 @@ %lld файл(ов) общим размером %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members Членов группы: %lld No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld минуты @@ -364,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s @@ -589,6 +621,10 @@ Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для Вас. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Все контакты, которые соединились через этот адрес, сохранятся. @@ -694,6 +730,14 @@ Соединение уже установлено? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Всегда соединяться через relay @@ -829,6 +873,18 @@ Улучшенные сообщения No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. И Вы, и Ваш контакт можете добавлять реакции на сообщения. @@ -1090,24 +1146,27 @@ Соединиться server test step - - Connect directly - Соединиться напрямую - No comment provided by engineer. - Connect incognito Соединиться Инкогнито No comment provided by engineer. - - Connect via contact link - Соединиться через ссылку-контакт + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Соединиться через ссылку группы? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1125,6 +1184,10 @@ Соединиться через одноразовую ссылку No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Устанавливается соединение с сервером… @@ -1170,11 +1233,6 @@ Существующий контакт No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Контакт и все сообщения будут удалены - это действие нельзя отменить! - No comment provided by engineer. - Contact hidden: Контакт скрыт: @@ -1225,6 +1283,10 @@ Версия ядра: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Создать @@ -1245,6 +1307,10 @@ Создание файла server test step + + Create group + No comment provided by engineer. + Create group link Создать ссылку группы @@ -1265,6 +1331,10 @@ Создать ссылку-приглашение No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Создание очереди @@ -1423,6 +1493,10 @@ Удалить chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Удалить контакт @@ -1448,6 +1522,10 @@ Удалить все файлы No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Удалить архив @@ -1478,9 +1556,9 @@ Удалить контакт No comment provided by engineer. - - Delete contact? - Удалить контакт? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1718,16 +1796,6 @@ Найдите и вступите в группы No comment provided by engineer. - - Display name - Имя профиля - No comment provided by engineer. - - - Display name: - Имя профиля: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. Не используйте SimpleX для экстренных звонков. @@ -1908,6 +1976,10 @@ Введите правильный пароль. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Введите пароль… @@ -1933,6 +2005,10 @@ Введите приветственное сообщение... (опционально) placeholder + + Enter your name… + No comment provided by engineer. + Error Ошибка @@ -2206,6 +2282,10 @@ Выйти без сохранения No comment provided by engineer. + + Expand + chat item action + Export database Экспорт архива чата @@ -2351,6 +2431,10 @@ Полное имя: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Полностью обновлены - работают в фоне! @@ -2371,6 +2455,14 @@ Группа No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Имя группы @@ -2718,6 +2810,10 @@ Ошибка в ссылке контакта No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Ошибка в адресе сервера! @@ -2809,11 +2905,24 @@ Вступить в группу No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Вступить инкогнито No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Вступление в группу @@ -3029,6 +3138,10 @@ Сообщения No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Данные чата перемещаются… @@ -3377,6 +3490,10 @@ Открыть консоль authentication reason + + Open group + No comment provided by engineer. + Open user profiles Открыть профили пользователя @@ -3392,11 +3509,6 @@ Открытие базы данных… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными. - No comment provided by engineer. - PING count Количество PING @@ -3587,6 +3699,14 @@ Аватар No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Пароль профиля @@ -3832,6 +3952,14 @@ Пересогласовать шифрование? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Ответить @@ -4546,6 +4674,10 @@ Нажмите кнопку No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Нажмите, чтобы сделать профиль активным. @@ -4643,11 +4775,6 @@ It can happen because of some bug or when the connection is compromised.Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Группа полностью децентрализована — она видна только членам. - No comment provided by engineer. - The hash of the previous message is different. Хэш предыдущего сообщения отличается. @@ -4743,6 +4870,14 @@ It can happen because of some bug or when the connection is compromised.Эта группа больше не существует. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**. @@ -4840,6 +4975,18 @@ You will be prompted to complete authentication before this feature is enabled.< Невозможно записать голосовое сообщение No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Неожиданная ошибка: %@ @@ -5187,6 +5334,35 @@ To connect, please ask your contact to create another connection link and check Вы уже соединены с контактом %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта. @@ -5282,6 +5458,15 @@ To connect, please ask your contact to create another connection link and check Верификация не удалась; пожалуйста, попробуйте ещё раз. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats У Вас нет чатов @@ -5332,6 +5517,10 @@ To connect, please ask your contact to create another connection link and check Соединение с группой будет установлено, когда хост группы будет онлайн. Пожалуйста, подождите или проверьте позже! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Соединение будет установлено, когда Ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! @@ -5347,9 +5536,8 @@ To connect, please ask your contact to create another connection link and check Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Вы вступите в группу, на которую ссылается эта ссылка, и соединитесь с её членами. + + You will connect to all group members. No comment provided by engineer. @@ -5417,11 +5605,6 @@ To connect, please ask your contact to create another connection link and check База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные. No comment provided by engineer. - - Your chat profile will be sent to group members - Ваш профиль чата будет отправлен членам группы - No comment provided by engineer. - Your chat profiles Ваши профили чата @@ -5476,6 +5659,10 @@ You can change it in Settings. Конфиденциальность No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Будет отправлен Ваш профиль **%@**. @@ -5568,6 +5755,10 @@ SimpleX серверы не могут получить доступ к Ваше всегда pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) аудиозвонок (не e2e зашифрованный) @@ -5583,6 +5774,10 @@ SimpleX серверы не могут получить доступ к Ваше ошибка хэш сообщения integrity error chat item + + blocked + No comment provided by engineer. + bold жирный @@ -5752,6 +5947,10 @@ SimpleX серверы не могут получить доступ к Ваше удалено deleted chat item + + deleted contact + rcv direct event chat item + deleted group удалил(а) группу @@ -6036,7 +6235,8 @@ SimpleX серверы не могут получить доступ к Ваше off нет enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6053,11 +6253,6 @@ SimpleX серверы не могут получить доступ к Ваше да group pref value - - or chat with the developers - или соединитесь с разработчиками - No comment provided by engineer. - owner владелец diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 19681b3150..44342f0885 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -84,6 +84,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected No comment provided by engineer. @@ -93,6 +97,10 @@ %1$@ ที่ %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ เชื่อมต่อสำเร็จ! @@ -118,6 +126,10 @@ %@ อยากเชื่อมต่อ! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected No comment provided by engineer. @@ -182,11 +194,27 @@ %lld ไฟล์ที่มีขนาดรวม %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld สมาชิก No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld นาที @@ -355,6 +383,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0s @@ -578,6 +610,10 @@ ข้อความทั้งหมดจะถูกลบ - ไม่สามารถยกเลิกได้! ข้อความจะถูกลบสำหรับคุณเท่านั้น. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. ผู้ติดต่อทั้งหมดของคุณจะยังคงเชื่อมต่ออยู่. @@ -683,6 +719,14 @@ เชื่อมต่อสำเร็จแล้ว? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay ใช้รีเลย์เสมอ @@ -817,6 +861,18 @@ ข้อความที่ดีขึ้น No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. ทั้งคุณและผู้ติดต่อของคุณสามารถเพิ่มปฏิกิริยาของข้อความได้ @@ -1077,21 +1133,26 @@ เชื่อมต่อ server test step - - Connect directly - No comment provided by engineer. - Connect incognito No comment provided by engineer. - - Connect via contact link + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - เชื่อมต่อผ่านลิงค์กลุ่ม? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1108,6 +1169,10 @@ Connect via one-time link No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… กำลังเชื่อมต่อกับเซิร์ฟเวอร์… @@ -1153,11 +1218,6 @@ ผู้ติดต่อรายนี้มีอยู่แล้ว No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - ผู้ติดต่อและข้อความทั้งหมดจะถูกลบ - ไม่สามารถยกเลิกได้! - No comment provided by engineer. - Contact hidden: ผู้ติดต่อถูกซ่อน: @@ -1208,6 +1268,10 @@ รุ่นหลัก: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create สร้าง @@ -1228,6 +1292,10 @@ สร้างไฟล์ server test step + + Create group + No comment provided by engineer. + Create group link สร้างลิงค์กลุ่ม @@ -1247,6 +1315,10 @@ สร้างลิงก์เชิญแบบใช้ครั้งเดียว No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue สร้างคิว @@ -1405,6 +1477,10 @@ ลบ chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact ลบผู้ติดต่อ @@ -1430,6 +1506,10 @@ ลบไฟล์ทั้งหมด No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive ลบที่เก็บถาวร @@ -1460,9 +1540,9 @@ ลบผู้ติดต่อ No comment provided by engineer. - - Delete contact? - ลบผู้ติดต่อ? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1698,16 +1778,6 @@ Discover and join groups No comment provided by engineer. - - Display name - ชื่อที่แสดง - No comment provided by engineer. - - - Display name: - ชื่อที่แสดง: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. อย่าใช้ SimpleX สําหรับการโทรฉุกเฉิน @@ -1886,6 +1956,10 @@ ใส่รหัสผ่านที่ถูกต้อง No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… ใส่รหัสผ่าน @@ -1911,6 +1985,10 @@ ใส่ข้อความต้อนรับ… (ไม่บังคับ) placeholder + + Enter your name… + No comment provided by engineer. + Error ผิดพลาด @@ -2183,6 +2261,10 @@ ออกโดยไม่บันทึก No comment provided by engineer. + + Expand + chat item action + Export database ส่งออกฐานข้อมูล @@ -2328,6 +2410,10 @@ ชื่อเต็ม: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! ดำเนินการใหม่อย่างสมบูรณ์ - ทำงานในพื้นหลัง! @@ -2348,6 +2434,14 @@ กลุ่ม No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name ชื่อกลุ่มที่แสดง @@ -2694,6 +2788,10 @@ ลิงค์เชื่อมต่อไม่ถูกต้อง No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! ที่อยู่เซิร์ฟเวอร์ไม่ถูกต้อง! @@ -2784,11 +2882,24 @@ เข้าร่วมกลุ่ม No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito เข้าร่วมแบบไม่ระบุตัวตน No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group กำลังจะเข้าร่วมกลุ่ม @@ -3004,6 +3115,10 @@ ข้อความและไฟล์ No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… กำลังย้ายข้อมูลที่เก็บถาวรของฐานข้อมูล… @@ -3349,6 +3464,10 @@ เปิดคอนโซลการแชท authentication reason + + Open group + No comment provided by engineer. + Open user profiles เปิดโปรไฟล์ผู้ใช้ @@ -3364,11 +3483,6 @@ กำลังเปิดฐานข้อมูล… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - การเปิดลิงก์ในเบราว์เซอร์อาจลดความเป็นส่วนตัวและความปลอดภัยของการเชื่อมต่อ ลิงก์ SimpleX ที่ไม่น่าเชื่อถือจะเป็นสีแดง - No comment provided by engineer. - PING count จํานวน PING @@ -3558,6 +3672,14 @@ รูปโปรไฟล์ No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password รหัสผ่านโปรไฟล์ @@ -3801,6 +3923,14 @@ เจรจา enryption ใหม่หรือไม่? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply ตอบ @@ -4510,6 +4640,10 @@ แตะปุ่ม No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. แตะเพื่อเปิดใช้งานโปรไฟล์ @@ -4608,11 +4742,6 @@ It can happen because of some bug or when the connection is compromised.encryption กำลังทำงานและไม่จำเป็นต้องใช้ข้อตกลง encryption ใหม่ อาจทำให้การเชื่อมต่อผิดพลาดได้! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - กลุ่มมีการกระจายอำนาจอย่างเต็มที่ – มองเห็นได้เฉพาะสมาชิกเท่านั้น - No comment provided by engineer. - The hash of the previous message is different. แฮชของข้อความก่อนหน้านี้แตกต่างกัน @@ -4706,6 +4835,14 @@ It can happen because of some bug or when the connection is compromised.ไม่มีกลุ่มนี้แล้ว No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. การตั้งค่านี้ใช้กับข้อความในโปรไฟล์แชทปัจจุบันของคุณ **%@** @@ -4802,6 +4939,18 @@ You will be prompted to complete authentication before this feature is enabled.< ไม่สามารถบันทึกข้อความเสียง No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ ข้อผิดพลาดที่ไม่คาดคิด: %@ @@ -5147,6 +5296,35 @@ To connect, please ask your contact to create another connection link and check คุณได้เชื่อมต่อกับ %@ แล้ว No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ @@ -5242,6 +5420,15 @@ To connect, please ask your contact to create another connection link and check เราไม่สามารถตรวจสอบคุณได้ กรุณาลองอีกครั้ง. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats คุณไม่มีการแชท @@ -5291,6 +5478,10 @@ To connect, please ask your contact to create another connection link and check คุณจะเชื่อมต่อกับกลุ่มเมื่ออุปกรณ์โฮสต์ของกลุ่มออนไลน์อยู่ โปรดรอหรือตรวจสอบภายหลัง! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! คุณจะเชื่อมต่อเมื่อคำขอเชื่อมต่อของคุณได้รับการยอมรับ โปรดรอหรือตรวจสอบในภายหลัง! @@ -5306,9 +5497,8 @@ To connect, please ask your contact to create another connection link and check คุณจะต้องตรวจสอบสิทธิ์เมื่อคุณเริ่มหรือกลับมาใช้แอปพลิเคชันอีกครั้งหลังจากผ่านไป 30 วินาทีในพื้นหลัง No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - คุณจะเข้าร่วมกลุ่มที่ลิงก์นี้อ้างถึงและเชื่อมต่อกับสมาชิกในกลุ่ม + + You will connect to all group members. No comment provided by engineer. @@ -5376,11 +5566,6 @@ To connect, please ask your contact to create another connection link and check ฐานข้อมูลการแชทของคุณไม่ได้ถูก encrypt - ตั้งรหัสผ่านเพื่อ encrypt No comment provided by engineer. - - Your chat profile will be sent to group members - โปรไฟล์การแชทของคุณจะถูกส่งไปยังสมาชิกในกลุ่ม - No comment provided by engineer. - Your chat profiles โปรไฟล์แชทของคุณ @@ -5435,6 +5620,10 @@ You can change it in Settings. ความเป็นส่วนตัวของคุณ No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. No comment provided by engineer. @@ -5526,6 +5715,10 @@ SimpleX servers cannot see your profile. เสมอ pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) การโทรด้วยเสียง (ไม่ได้ encrypt จากต้นจนจบ) @@ -5541,6 +5734,10 @@ SimpleX servers cannot see your profile. แฮชข้อความไม่ดี integrity error chat item + + blocked + No comment provided by engineer. + bold ตัวหนา @@ -5710,6 +5907,10 @@ SimpleX servers cannot see your profile. ลบแล้ว deleted chat item + + deleted contact + rcv direct event chat item + deleted group กลุ่มที่ถูกลบ @@ -5992,7 +6193,8 @@ SimpleX servers cannot see your profile. off ปิด enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6009,11 +6211,6 @@ SimpleX servers cannot see your profile. เปิด group pref value - - or chat with the developers - หรือแชทกับนักพัฒนาแอป - No comment provided by engineer. - owner เจ้าของ diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 4947bf80c5..b4ce70f7df 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ і %@ підключено @@ -97,6 +101,10 @@ %1$@ за %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ підключено! @@ -122,6 +130,10 @@ %@ хоче підключитися! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ та %lld інші підключені учасники @@ -187,11 +199,27 @@ %lld файл(и) загальним розміром %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld учасників No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld хвилин @@ -360,6 +388,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s @@ -585,6 +617,10 @@ Всі повідомлення будуть видалені - це неможливо скасувати! Повідомлення будуть видалені ТІЛЬКИ для вас. No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. Всі ваші контакти залишаться на зв'язку. @@ -690,6 +726,14 @@ Вже підключено? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay Завжди використовуйте реле @@ -824,6 +868,18 @@ Кращі повідомлення No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. Реакції на повідомлення можете додавати як ви, так і ваш контакт. @@ -1084,24 +1140,27 @@ Підключіться server test step - - Connect directly - Підключіться безпосередньо - No comment provided by engineer. - Connect incognito Підключайтеся інкогніто No comment provided by engineer. - - Connect via contact link - Підключіться за контактним посиланням + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - Підключитися за груповим посиланням? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1119,6 +1178,10 @@ Під'єднатися за одноразовим посиланням No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… Підключення до сервера… @@ -1164,11 +1227,6 @@ Контакт вже існує No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - Контакт і всі повідомлення будуть видалені - це неможливо скасувати! - No comment provided by engineer. - Contact hidden: Контакт приховано: @@ -1219,6 +1277,10 @@ Основна версія: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create Створити @@ -1239,6 +1301,10 @@ Створити файл server test step + + Create group + No comment provided by engineer. + Create group link Створити групове посилання @@ -1258,6 +1324,10 @@ Створіть одноразове посилання-запрошення No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue Створити чергу @@ -1416,6 +1486,10 @@ Видалити chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact Видалити контакт @@ -1441,6 +1515,10 @@ Видалити всі файли No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive Видалити архів @@ -1471,9 +1549,9 @@ Видалити контакт No comment provided by engineer. - - Delete contact? - Видалити контакт? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1710,16 +1788,6 @@ Discover and join groups No comment provided by engineer. - - Display name - Відображуване ім'я - No comment provided by engineer. - - - Display name: - Відображуване ім'я: - No comment provided by engineer. - Do NOT use SimpleX for emergency calls. НЕ використовуйте SimpleX для екстрених викликів. @@ -1898,6 +1966,10 @@ Введіть правильну парольну фразу. No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… Введіть пароль… @@ -1923,6 +1995,10 @@ Введіть вітальне повідомлення... (необов'язково) placeholder + + Enter your name… + No comment provided by engineer. + Error Помилка @@ -2195,6 +2271,10 @@ Вихід без збереження No comment provided by engineer. + + Expand + chat item action + Export database Експорт бази даних @@ -2340,6 +2420,10 @@ Повне ім'я: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! Повністю перероблено - робота у фоновому режимі! @@ -2360,6 +2444,14 @@ Група No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name Назва групи для відображення @@ -2707,6 +2799,10 @@ Неправильне посилання для підключення No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! Неправильна адреса сервера! @@ -2798,11 +2894,24 @@ Приєднуйтесь до групи No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito Приєднуйтесь інкогніто No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group Приєднання до групи @@ -3018,6 +3127,10 @@ Повідомлення та файли No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… Перенесення архіву бази даних… @@ -3365,6 +3478,10 @@ Відкрийте консоль чату authentication reason + + Open group + No comment provided by engineer. + Open user profiles Відкрити профілі користувачів @@ -3380,11 +3497,6 @@ Відкриття бази даних… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - Відкриття посилання в браузері може знизити конфіденційність і безпеку з'єднання. Ненадійні посилання SimpleX будуть червоного кольору. - No comment provided by engineer. - PING count Кількість PING @@ -3575,6 +3687,14 @@ Зображення профілю No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password Пароль до профілю @@ -3820,6 +3940,14 @@ Переузгодьте шифрування? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply Відповісти @@ -4533,6 +4661,10 @@ Натисніть кнопку No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. Натисніть, щоб активувати профіль. @@ -4630,11 +4762,6 @@ It can happen because of some bug or when the connection is compromised.Шифрування працює і нова угода про шифрування не потрібна. Це може призвести до помилок з'єднання! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - Група повністю децентралізована - її бачать лише учасники. - No comment provided by engineer. - The hash of the previous message is different. Хеш попереднього повідомлення відрізняється. @@ -4730,6 +4857,14 @@ It can happen because of some bug or when the connection is compromised.Цієї групи більше не існує. No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. Це налаштування застосовується до повідомлень у вашому поточному профілі чату **%@**. @@ -4826,6 +4961,18 @@ You will be prompted to complete authentication before this feature is enabled.< Не вдається записати голосове повідомлення No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ Неочікувана помилка: %@ @@ -5173,6 +5320,35 @@ To connect, please ask your contact to create another connection link and check Ви вже підключені до %@. No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту. @@ -5268,6 +5444,15 @@ To connect, please ask your contact to create another connection link and check Вас не вдалося верифікувати, спробуйте ще раз. No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats У вас немає чатів @@ -5318,6 +5503,10 @@ To connect, please ask your contact to create another connection link and check Ви будете підключені до групи, коли пристрій господаря групи буде в мережі, будь ласка, зачекайте або перевірте пізніше! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! Ви будете підключені, коли ваш запит на підключення буде прийнято, будь ласка, зачекайте або перевірте пізніше! @@ -5333,9 +5522,8 @@ To connect, please ask your contact to create another connection link and check Вам потрібно буде пройти автентифікацію при запуску або відновленні програми після 30 секунд роботи у фоновому режимі. No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - Ви приєднаєтеся до групи, на яку посилається це посилання, і з'єднаєтеся з її учасниками. + + You will connect to all group members. No comment provided by engineer. @@ -5403,11 +5591,6 @@ To connect, please ask your contact to create another connection link and check Ваша база даних чату не зашифрована - встановіть ключову фразу, щоб зашифрувати її. No comment provided by engineer. - - Your chat profile will be sent to group members - Ваш профіль у чаті буде надіслано учасникам групи - No comment provided by engineer. - Your chat profiles Ваші профілі чату @@ -5462,6 +5645,10 @@ You can change it in Settings. Ваша конфіденційність No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. Ваш профіль **%@** буде опублікований. @@ -5554,6 +5741,10 @@ SimpleX servers cannot see your profile. завжди pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) аудіовиклик (без шифрування e2e) @@ -5569,6 +5760,10 @@ SimpleX servers cannot see your profile. невірний хеш повідомлення integrity error chat item + + blocked + No comment provided by engineer. + bold жирний @@ -5738,6 +5933,10 @@ SimpleX servers cannot see your profile. видалено deleted chat item + + deleted contact + rcv direct event chat item + deleted group видалено групу @@ -6022,7 +6221,8 @@ SimpleX servers cannot see your profile. off вимкнено enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6039,11 +6239,6 @@ SimpleX servers cannot see your profile. увімкнено group pref value - - or chat with the developers - або поспілкуйтеся з розробниками - No comment provided by engineer. - owner власник diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 9fcd6d0bf2..fba15d68cf 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -87,6 +87,10 @@ %@ / %@ No comment provided by engineer. + + %@ and %@ + No comment provided by engineer. + %@ and %@ connected %@ 和%@ 以建立连接 @@ -97,6 +101,10 @@ @ %2$@: copied message info, <sender> at <time> + + %@ connected + No comment provided by engineer. + %@ is connected! %@ 已连接! @@ -122,6 +130,10 @@ %@ 要连接! notification title + + %@, %@ and %lld members + No comment provided by engineer. + %@, %@ and %lld other members connected %@, %@ 和 %lld 个成员 @@ -187,11 +199,27 @@ %lld 总文件大小 %@ No comment provided by engineer. + + %lld group events + No comment provided by engineer. + %lld members %lld 成员 No comment provided by engineer. + + %lld messages blocked + No comment provided by engineer. + + + %lld messages marked deleted + No comment provided by engineer. + + + %lld messages moderated by %@ + No comment provided by engineer. + %lld minutes %lld 分钟 @@ -336,6 +364,9 @@ - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. + - 连接 [目录服务](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! +- 发送回执(最多20成员)。 +- 更快并且更稳固。 No comment provided by engineer. @@ -361,6 +392,10 @@ . No comment provided by engineer. + + 0 sec + time to disappear + 0s 0秒 @@ -586,6 +621,10 @@ 所有聊天记录和消息将被删除——这一行为无法撤销!只有您的消息会被删除。 No comment provided by engineer. + + All new messages from %@ will be hidden! + No comment provided by engineer. + All your contacts will remain connected. 所有联系人会保持连接。 @@ -691,6 +730,14 @@ 已连接? No comment provided by engineer. + + Already connecting! + No comment provided by engineer. + + + Already joining the group! + No comment provided by engineer. + Always use relay 一直使用中继 @@ -713,6 +760,7 @@ App encrypts new local files (except videos). + 应用程序为新的本地文件(视频除外)加密。 No comment provided by engineer. @@ -825,6 +873,18 @@ 更好的消息 No comment provided by engineer. + + Block + No comment provided by engineer. + + + Block member + No comment provided by engineer. + + + Block member? + No comment provided by engineer. + Both you and your contact can add message reactions. 您和您的联系人都可以添加消息回应。 @@ -852,6 +912,7 @@ Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + 保加利亚语、芬兰语、泰语和乌克兰语——感谢用户和[Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! No comment provided by engineer. @@ -1085,24 +1146,27 @@ 连接 server test step - - Connect directly - 直接连接 - No comment provided by engineer. - Connect incognito 在隐身状态下连接 No comment provided by engineer. - - Connect via contact link - 通过联系人链接进行连接 + + Connect to yourself? No comment provided by engineer. - - Connect via group link? - 通过群组链接连接? + + Connect to yourself? +This is your own SimpleX address! + No comment provided by engineer. + + + Connect to yourself? +This is your own one-time link! + No comment provided by engineer. + + + Connect via contact address No comment provided by engineer. @@ -1120,6 +1184,10 @@ 通过一次性链接连接 No comment provided by engineer. + + Connect with %@ + No comment provided by engineer. + Connecting to server… 连接服务器中…… @@ -1165,11 +1233,6 @@ 联系人已存在 No comment provided by engineer. - - Contact and all messages will be deleted - this cannot be undone! - 联系人和所有的消息都将被删除——这是不可逆回的! - No comment provided by engineer. - Contact hidden: 联系人已隐藏: @@ -1220,6 +1283,10 @@ 核心版本: v%@ No comment provided by engineer. + + Correct name to %@? + No comment provided by engineer. + Create 创建 @@ -1240,6 +1307,10 @@ 创建文件 server test step + + Create group + No comment provided by engineer. + Create group link 创建群组链接 @@ -1252,6 +1323,7 @@ Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + 在[桌面应用程序](https://simplex.chat/downloads/)中创建新的个人资料。 💻 No comment provided by engineer. @@ -1259,6 +1331,10 @@ 创建一次性邀请链接 No comment provided by engineer. + + Create profile + No comment provided by engineer. + Create queue 创建队列 @@ -1417,6 +1493,10 @@ 删除 chat item action + + Delete %lld messages? + No comment provided by engineer. + Delete Contact 删除联系人 @@ -1442,6 +1522,10 @@ 删除所有文件 No comment provided by engineer. + + Delete and notify contact + No comment provided by engineer. + Delete archive 删除档案 @@ -1472,9 +1556,9 @@ 删除联系人 No comment provided by engineer. - - Delete contact? - 删除联系人? + + Delete contact? +This cannot be undone! No comment provided by engineer. @@ -1709,16 +1793,7 @@ Discover and join groups - No comment provided by engineer. - - - Display name - 显示名称 - No comment provided by engineer. - - - Display name: - 显示名: + 发现和加入群组 No comment provided by engineer. @@ -1853,6 +1928,7 @@ Encrypt stored files & media + 为存储的文件和媒体加密 No comment provided by engineer. @@ -1900,6 +1976,10 @@ 输入正确密码。 No comment provided by engineer. + + Enter group name… + No comment provided by engineer. + Enter passphrase… 输入密码…… @@ -1925,6 +2005,10 @@ 输入欢迎消息……(可选) placeholder + + Enter your name… + No comment provided by engineer. + Error 错误 @@ -1982,6 +2066,7 @@ Error creating member contact + 创建成员联系人时出错 No comment provided by engineer. @@ -2116,6 +2201,7 @@ Error sending member contact invitation + 发送成员联系人邀请错误 No comment provided by engineer. @@ -2198,6 +2284,10 @@ 退出而不保存 No comment provided by engineer. + + Expand + chat item action + Export database 导出数据库 @@ -2343,6 +2433,10 @@ 全名: No comment provided by engineer. + + Fully decentralized – visible only to members. + No comment provided by engineer. + Fully re-implemented - work in background! 完全重新实现 - 在后台工作! @@ -2363,6 +2457,14 @@ 群组 No comment provided by engineer. + + Group already exists + No comment provided by engineer. + + + Group already exists! + No comment provided by engineer. + Group display name 群组显示名称 @@ -2710,6 +2812,10 @@ 无效的连接链接 No comment provided by engineer. + + Invalid name! + No comment provided by engineer. + Invalid server address! 无效的服务器地址! @@ -2801,11 +2907,24 @@ 加入群组 No comment provided by engineer. + + Join group? + No comment provided by engineer. + Join incognito 加入隐身聊天 No comment provided by engineer. + + Join with current profile + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + No comment provided by engineer. + Joining group 加入群组中 @@ -3021,6 +3140,10 @@ 消息 No comment provided by engineer. + + Messages from %@ will be shown! + No comment provided by engineer. + Migrating database archive… 迁移数据库档案中… @@ -3133,6 +3256,7 @@ New desktop app! + 全新桌面应用! No comment provided by engineer. @@ -3351,6 +3475,7 @@ Open + 打开 No comment provided by engineer. @@ -3368,6 +3493,10 @@ 打开聊天控制台 authentication reason + + Open group + No comment provided by engineer. + Open user profiles 打开用户个人资料 @@ -3383,11 +3512,6 @@ 打开数据库中…… No comment provided by engineer. - - Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. - 在浏览器中打开链接可能会降低连接的隐私和安全性。SimpleX 上不受信任的链接将显示为红色。 - No comment provided by engineer. - PING count PING 次数 @@ -3578,6 +3702,14 @@ 资料图片 No comment provided by engineer. + + Profile name + No comment provided by engineer. + + + Profile name: + No comment provided by engineer. + Profile password 个人资料密码 @@ -3823,6 +3955,14 @@ 重新协商加密? No comment provided by engineer. + + Repeat connection request? + No comment provided by engineer. + + + Repeat join request? + No comment provided by engineer. + Reply 回复 @@ -4090,6 +4230,7 @@ Send direct message to connect + 发送私信来连接 No comment provided by engineer. @@ -4394,6 +4535,7 @@ Simplified incognito mode + 简化的隐身模式 No comment provided by engineer. @@ -4536,6 +4678,10 @@ 点击按钮 No comment provided by engineer. + + Tap to Connect + No comment provided by engineer. + Tap to activate profile. 点击以激活个人资料。 @@ -4633,11 +4779,6 @@ It can happen because of some bug or when the connection is compromised.加密正在运行,不需要新的加密协议。这可能会导致连接错误! No comment provided by engineer. - - The group is fully decentralized – it is visible only to the members. - 该小组是完全分散式的——它只对成员可见。 - No comment provided by engineer. - The hash of the previous message is different. 上一条消息的散列不同。 @@ -4733,6 +4874,14 @@ It can happen because of some bug or when the connection is compromised.该群组已不存在。 No comment provided by engineer. + + This is your own SimpleX address! + No comment provided by engineer. + + + This is your own one-time link! + No comment provided by engineer. + This setting applies to messages in your current chat profile **%@**. 此设置适用于您当前聊天资料 **%@** 中的消息。 @@ -4792,6 +4941,7 @@ You will be prompted to complete authentication before this feature is enabled.< Toggle incognito when connecting. + 在连接时切换隐身模式。 No comment provided by engineer. @@ -4829,6 +4979,18 @@ You will be prompted to complete authentication before this feature is enabled.< 无法录制语音消息 No comment provided by engineer. + + Unblock + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + Unexpected error: %@ 意外错误: %@ @@ -5176,6 +5338,35 @@ To connect, please ask your contact to create another connection link and check 您已经连接到 %@。 No comment provided by engineer. + + You are already connecting to %@. + No comment provided by engineer. + + + You are already connecting via this one-time link! + No comment provided by engineer. + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + No comment provided by engineer. + + + You are already joining the group via this link! + No comment provided by engineer. + + + You are already joining the group via this link. + No comment provided by engineer. + + + You are already joining the group! +Repeat join request? + No comment provided by engineer. + You are connected to the server used to receive messages from this contact. 您已连接到用于接收该联系人消息的服务器。 @@ -5271,6 +5462,15 @@ To connect, please ask your contact to create another connection link and check 您的身份无法验证,请再试一次。 No comment provided by engineer. + + You have already requested connection via this address! + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + No comment provided by engineer. + You have no chats 您没有聊天记录 @@ -5321,6 +5521,10 @@ To connect, please ask your contact to create another connection link and check 您将在组主设备上线时连接到该群组,请稍等或稍后再检查! No comment provided by engineer. + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + You will be connected when your connection request is accepted, please wait or check later! 当您的连接请求被接受后,您将可以连接,请稍等或稍后检查! @@ -5336,9 +5540,8 @@ To connect, please ask your contact to create another connection link and check 当您启动应用或在应用程序驻留后台超过30 秒后,您将需要进行身份验证。 No comment provided by engineer. - - You will join a group this link refers to and connect to its group members. - 您将加入此链接指向的群组并连接到其群组成员。 + + You will connect to all group members. No comment provided by engineer. @@ -5406,11 +5609,6 @@ To connect, please ask your contact to create another connection link and check 您的聊天数据库未加密——设置密码来加密。 No comment provided by engineer. - - Your chat profile will be sent to group members - 您的聊天资料将被发送给群组成员 - No comment provided by engineer. - Your chat profiles 您的聊天资料 @@ -5465,6 +5663,10 @@ You can change it in Settings. 您的隐私设置 No comment provided by engineer. + + Your profile + No comment provided by engineer. + Your profile **%@** will be shared. 您的个人资料 **%@** 将被共享。 @@ -5557,6 +5759,10 @@ SimpleX 服务器无法看到您的资料。 始终 pref value + + and %lld other events + No comment provided by engineer. + audio call (not e2e encrypted) 语音通话(非端到端加密) @@ -5572,6 +5778,10 @@ SimpleX 服务器无法看到您的资料。 错误消息散列 integrity error chat item + + blocked + No comment provided by engineer. + bold 加粗 @@ -5644,6 +5854,7 @@ SimpleX 服务器无法看到您的资料。 connected directly + 已直连 rcv group event chat item @@ -5741,6 +5952,10 @@ SimpleX 服务器无法看到您的资料。 已删除 deleted chat item + + deleted contact + rcv direct event chat item + deleted group 已删除群组 @@ -6025,7 +6240,8 @@ SimpleX 服务器无法看到您的资料。 off 关闭 enabled status - group pref value + group pref value + time to disappear offered %@ @@ -6042,11 +6258,6 @@ SimpleX 服务器无法看到您的资料。 开启 group pref value - - or chat with the developers - 或与开发者聊天 - No comment provided by engineer. - owner 群主 @@ -6109,6 +6320,7 @@ SimpleX 服务器无法看到您的资料。 send direct message + 发送私信 No comment provided by engineer. diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index c01e3d7e66..5a704457d1 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Свързване"; -/* No comment provided by engineer. */ -"Connect directly" = "Свързване директно"; - /* No comment provided by engineer. */ "Connect incognito" = "Свързване инкогнито"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "свържете се с разработчиците на SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Свързване чрез линк на контакта"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Свързване чрез групов линк?"; - /* No comment provided by engineer. */ "Connect via link" = "Свърване чрез линк"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "свързан"; +/* rcv group event chat item */ +"connected directly" = "свързан директно"; + /* No comment provided by engineer. */ "connecting" = "свързване"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Контактът вече съществува"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Контактът и всички съобщения ще бъдат изтрити - това не може да бъде отменено!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "контактът има e2e криптиране"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Изтрий контакт"; -/* No comment provided by engineer. */ -"Delete contact?" = "Изтрий контакт?"; - /* No comment provided by engineer. */ "Delete database" = "Изтрий базата данни"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Открийте и се присъединете към групи"; -/* No comment provided by engineer. */ -"Display name" = "Показвано Име"; - -/* No comment provided by engineer. */ -"Display name:" = "Показвано име:"; - /* No comment provided by engineer. */ "Do it later" = "Отложи"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Грешка при създаване на групов линк"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Грешка при създаване на контакт с член"; + /* No comment provided by engineer. */ "Error creating profile!" = "Грешка при създаване на профил!"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Грешка при изпращане на имейл"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Грешка при изпращане на съобщение за покана за контакт"; + /* No comment provided by engineer. */ "Error sending message" = "Грешка при изпращане на съобщение"; @@ -2227,7 +2215,8 @@ "observer" = "наблюдател"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "изключено"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Само вашият контакт може да изпраща гласови съобщения."; +/* No comment provided by engineer. */ +"Open" = "Отвори"; + /* No comment provided by engineer. */ "Open chat" = "Отвори чат"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Отваряне на база данни…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Отварянето на линка в браузъра може да намали поверителността и сигурността на връзката. Несигурните SimpleX линкове ще бъдат червени."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "или пишете на разработчиците"; - /* member role */ "owner" = "собственик"; @@ -2782,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Изпращайте потвърждениe за доставка на"; +/* No comment provided by engineer. */ +"send direct message" = "изпрати лично съобщение"; + /* No comment provided by engineer. */ "Send direct message" = "Изпрати лично съобщение"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Изпрати лично съобщение за свързване"; + /* No comment provided by engineer. */ "Send disappearing message" = "Изпрати изчезващо съобщение"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Криптирането работи и новото споразумение за криптиране не е необходимо. Това може да доведе до грешки при свързване!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Групата е напълно децентрализирана – видима е само за членовете."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Хешът на предишното съобщение е различен."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Ще трябва да се идентифицирате, когато стартирате или възобновите приложението след 30 секунди във фонов режим."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Ще се присъедините към групата, към която този линк препраща, и ще се свържете с нейните членове."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Все още ще получавате обаждания и известия от заглушени профили, когато са активни."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Вашата чат база данни не е криптирана - задайте парола, за да я криптирате."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Вашият чат профил ще бъде изпратен на членовете на групата"; - /* No comment provided by engineer. */ "Your chat profiles" = "Вашите чат профили"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 3d7d5f8fe2..26469bea98 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_kurzíva_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- připojit k [adresářová služba](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.cibule) (BETA)!\n- doručenky (až 20 členů).\n- Rychlejší a stabilnější."; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- více stabilní doručování zpráv.\n- o trochu lepší skupiny.\n- a více!"; @@ -181,6 +184,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld minut"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%d nové jazyky rozhraní"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld vteřin"; @@ -443,6 +449,9 @@ /* No comment provided by engineer. */ "App build: %@" = "Sestavení aplikace: %@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "Aplikace šifruje nové místní soubory (s výjimkou videí)."; + /* No comment provided by engineer. */ "App icon" = "Ikona aplikace"; @@ -536,6 +545,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Hlasové zprávy můžete posílat vy i váš kontakt."; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulharský, finský, thajský a ukrajinský - díky uživatelům a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -708,21 +720,12 @@ /* server test step */ "Connect" = "Připojit"; -/* No comment provided by engineer. */ -"Connect directly" = "Připojit přímo"; - /* No comment provided by engineer. */ "Connect incognito" = "Spojit se inkognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "připojit se k vývojářům SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Připojit se přes odkaz"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Připojit se přes odkaz skupiny?"; - /* No comment provided by engineer. */ "Connect via link" = "Připojte se prostřednictvím odkazu"; @@ -735,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "připojeno"; +/* rcv group event chat item */ +"connected directly" = "připojeno přímo"; + /* No comment provided by engineer. */ "connecting" = "připojování"; @@ -789,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Kontakt již existuje"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Kontakt a všechny zprávy budou smazány - nelze to vzít zpět!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "kontakt má šifrování e2e"; @@ -843,6 +846,9 @@ /* No comment provided by engineer. */ "Create link" = "Vytvořit odkaz"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Vytvořit nový profil v [desktop app](https://simplex.chat/downloads/). 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "Vytvořit jednorázovou pozvánku"; @@ -993,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Smazat kontakt"; -/* No comment provided by engineer. */ -"Delete contact?" = "Smazat kontakt?"; - /* No comment provided by engineer. */ "Delete database" = "Odstranění databáze"; @@ -1125,6 +1128,9 @@ /* authentication reason */ "Disable SimpleX Lock" = "Vypnutí zámku SimpleX"; +/* No comment provided by engineer. */ +"disabled" = "vypnut"; + /* No comment provided by engineer. */ "Disappearing message" = "Mizící zpráva"; @@ -1147,10 +1153,7 @@ "Disconnect" = "Odpojit"; /* No comment provided by engineer. */ -"Display name" = "Zobrazované jméno"; - -/* No comment provided by engineer. */ -"Display name:" = "Zobrazované jméno:"; +"Discover and join groups" = "Objevte a připojte skupiny"; /* No comment provided by engineer. */ "Do it later" = "Udělat později"; @@ -1242,6 +1245,12 @@ /* No comment provided by engineer. */ "Encrypt database?" = "Šifrovat databázi?"; +/* No comment provided by engineer. */ +"Encrypt local files" = "Šifrovat místní soubory"; + +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "Šifrovat uložené soubory a média"; + /* No comment provided by engineer. */ "Encrypted database" = "Zašifrovaná databáze"; @@ -1350,9 +1359,15 @@ /* No comment provided by engineer. */ "Error creating group link" = "Chyba při vytváření odkazu skupiny"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Chyba vytvoření kontaktu člena"; + /* No comment provided by engineer. */ "Error creating profile!" = "Chyba při vytváření profilu!"; +/* No comment provided by engineer. */ +"Error decrypting file" = "Chyba dešifrování souboru"; + /* No comment provided by engineer. */ "Error deleting chat database" = "Chyba při mazání databáze chatu"; @@ -1425,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Chyba odesílání e-mailu"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Chyba odeslání pozvánky kontaktu"; + /* No comment provided by engineer. */ "Error sending message" = "Chyba při odesílání zprávy"; @@ -2115,6 +2133,9 @@ /* No comment provided by engineer. */ "New database archive" = "Archiv nové databáze"; +/* No comment provided by engineer. */ +"New desktop app!" = "Nová desktopová aplikace!"; + /* No comment provided by engineer. */ "New display name" = "Nově zobrazované jméno"; @@ -2151,6 +2172,9 @@ /* No comment provided by engineer. */ "No contacts to add" = "Žádné kontakty k přidání"; +/* No comment provided by engineer. */ +"No delivery information" = "Žádné informace o dodání"; + /* No comment provided by engineer. */ "No device token!" = "Žádný token zařízení!"; @@ -2188,7 +2212,8 @@ "observer" = "pozorovatel"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "vypnuto"; /* No comment provided by engineer. */ @@ -2269,6 +2294,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Hlasové zprávy může odesílat pouze váš kontakt."; +/* No comment provided by engineer. */ +"Open" = "Otevřít"; + /* No comment provided by engineer. */ "Open chat" = "Otevřete chat"; @@ -2287,12 +2315,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Otvírání databáze…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Otevření odkazu v prohlížeči může snížit soukromí a bezpečnost připojení. Nedůvěryhodné odkazy SimpleX budou červené."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "nebo chat s vývojáři"; - /* member role */ "owner" = "vlastník"; @@ -2482,6 +2504,9 @@ /* No comment provided by engineer. */ "Read more in our GitHub repository." = "Další informace najdete v našem repozitáři GitHub."; +/* No comment provided by engineer. */ +"Receipts are disabled" = "Informace o dodání jsou zakázány"; + /* No comment provided by engineer. */ "received answer…" = "obdržel odpověď…"; @@ -2740,9 +2765,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Potvrzení o doručení zasílat na"; +/* No comment provided by engineer. */ +"send direct message" = "odeslat přímou zprávu"; + /* No comment provided by engineer. */ "Send direct message" = "Odeslat přímou zprávu"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Odeslat přímou zprávu pro připojení"; + /* No comment provided by engineer. */ "Send disappearing message" = "Poslat mizící zprávu"; @@ -2785,9 +2816,15 @@ /* No comment provided by engineer. */ "Sending receipts is disabled for %lld contacts" = "Odesílání potvrzení o doručení je vypnuto pro %lld kontakty"; +/* No comment provided by engineer. */ +"Sending receipts is disabled for %lld groups" = "Odesílání potvrzení o doručení vypnuto pro %lld skupiny"; + /* No comment provided by engineer. */ "Sending receipts is enabled for %lld contacts" = "Odesílání potvrzení o doručení je povoleno pro %lld kontakty"; +/* No comment provided by engineer. */ +"Sending receipts is enabled for %lld groups" = "Odesílání potvrzení o doručení povoleno pro %lld skupiny"; + /* No comment provided by engineer. */ "Sending via" = "Odesílání přes"; @@ -2872,6 +2909,9 @@ /* No comment provided by engineer. */ "Show developer options" = "Zobrazit možnosti vývojáře"; +/* No comment provided by engineer. */ +"Show last messages" = "Zobrazit poslední zprávy"; + /* No comment provided by engineer. */ "Show preview" = "Zobrazení náhledu"; @@ -2914,12 +2954,18 @@ /* simplex link type */ "SimpleX one-time invitation" = "Jednorázová pozvánka SimpleX"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "Zjednodušený inkognito režim"; + /* No comment provided by engineer. */ "Skip" = "Přeskočit"; /* No comment provided by engineer. */ "Skipped messages" = "Přeskočené zprávy"; +/* No comment provided by engineer. */ +"Small groups (max 20)" = "Malé skupiny (max. 20)"; + /* No comment provided by engineer. */ "SMP servers" = "SMP servery"; @@ -3058,9 +3104,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Šifrování funguje a nové povolení šifrování není vyžadováno. To může vyvolat chybu v připojení!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Skupina je plně decentralizovaná - je viditelná pouze pro členy."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Hash předchozí zprávy se liší."; @@ -3118,6 +3161,9 @@ /* notification title */ "this contact" = "tento kontakt"; +/* No comment provided by engineer. */ +"This group has over %lld members, delivery receipts are not sent." = "Tato skupina má více než %lld členů, potvrzení o doručení nejsou odesílány."; + /* No comment provided by engineer. */ "This group no longer exists." = "Tato skupina již neexistuje."; @@ -3154,6 +3200,9 @@ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Chcete-li ověřit koncové šifrování u svého kontaktu, porovnejte (nebo naskenujte) kód na svých zařízeních."; +/* No comment provided by engineer. */ +"Toggle incognito when connecting." = "Změnit inkognito režim při připojení."; + /* No comment provided by engineer. */ "Transport isolation" = "Izolace transportu"; @@ -3262,12 +3311,18 @@ /* No comment provided by engineer. */ "Use chat" = "Použijte chat"; +/* No comment provided by engineer. */ +"Use current profile" = "Použít aktuální profil"; + /* No comment provided by engineer. */ "Use for new connections" = "Použít pro nová připojení"; /* No comment provided by engineer. */ "Use iOS call interface" = "Použít rozhraní volání iOS"; +/* No comment provided by engineer. */ +"Use new incognito profile" = "Použít nový inkognito profil"; + /* No comment provided by engineer. */ "Use server" = "Použít server"; @@ -3541,9 +3596,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Při spuštění nebo obnovení aplikace po 30 sekundách na pozadí budete požádáni o ověření."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Připojíte se ke skupině, na kterou tento odkaz odkazuje, a spojíte se s jejími členy."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Stále budete přijímat volání a upozornění od umlčených profilů pokud budou aktivní."; @@ -3574,9 +3626,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Vaše chat databáze není šifrována – nastavte přístupovou frázi pro její šifrování."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Váš chat profil bude zaslán členům skupiny"; - /* No comment provided by engineer. */ "Your chat profiles" = "Vaše chat profily"; @@ -3610,6 +3659,9 @@ /* No comment provided by engineer. */ "Your privacy" = "Vaše soukromí"; +/* No comment provided by engineer. */ +"Your profile **%@** will be shared." = "Váš profil **%@** bude sdílen."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Váš profil je uložen ve vašem zařízení a sdílen pouze s vašimi kontakty.\nServery SimpleX nevidí váš profil."; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 111ce0d916..2fe8d87b69 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_kursiv_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- Verbinden mit dem [Directory-Service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- Empfangsbestätigungen (für bis zu 20 Mitglieder).\n- Schneller und stabiler."; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilere Zustellung von Nachrichten.\n- ein bisschen verbesserte Gruppen.\n- und mehr!"; @@ -181,6 +184,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld Minuten"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%lld neue Sprachen für die Bedienoberfläche"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld Sekunde(n)"; @@ -443,6 +449,9 @@ /* No comment provided by engineer. */ "App build: %@" = "App Build: %@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt."; + /* No comment provided by engineer. */ "App icon" = "App-Icon"; @@ -536,6 +545,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden."; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgarisch, Finnisch, Thailändisch und Ukrainisch - Dank der Nutzer und [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -708,21 +720,12 @@ /* server test step */ "Connect" = "Verbinden"; -/* No comment provided by engineer. */ -"Connect directly" = "Direkt verbinden"; - /* No comment provided by engineer. */ "Connect incognito" = "Inkognito verbinden"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "Mit den SimpleX Chat-Entwicklern verbinden."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Über den Kontakt-Link verbinden"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Über den Gruppen-Link verbinden?"; - /* No comment provided by engineer. */ "Connect via link" = "Über einen Link verbinden"; @@ -735,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "Verbunden"; +/* rcv group event chat item */ +"connected directly" = "Direkt miteinander verbunden"; + /* No comment provided by engineer. */ "connecting" = "verbinde"; @@ -789,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Der Kontakt ist bereits vorhanden"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Der Kontakt und alle Nachrichten werden gelöscht - dies kann nicht rückgängig gemacht werden!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "Kontakt nutzt E2E-Verschlüsselung"; @@ -843,6 +846,9 @@ /* No comment provided by engineer. */ "Create link" = "Link erzeugen"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Neues Profil in der [Desktop-App] erstellen (https://simplex.chat/downloads/). 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "Einmal-Einladungslink erstellen"; @@ -993,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Kontakt löschen"; -/* No comment provided by engineer. */ -"Delete contact?" = "Kontakt löschen?"; - /* No comment provided by engineer. */ "Delete database" = "Datenbank löschen"; @@ -1150,10 +1153,7 @@ "Disconnect" = "Trennen"; /* No comment provided by engineer. */ -"Display name" = "Angezeigter Name"; - -/* No comment provided by engineer. */ -"Display name:" = "Angezeigter Name:"; +"Discover and join groups" = "Gruppen entdecken und ihnen beitreten"; /* No comment provided by engineer. */ "Do it later" = "Später wiederholen"; @@ -1248,6 +1248,9 @@ /* No comment provided by engineer. */ "Encrypt local files" = "Lokale Dateien verschlüsseln"; +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "Gespeicherte Dateien & Medien verschlüsseln"; + /* No comment provided by engineer. */ "Encrypted database" = "Verschlüsselte Datenbank"; @@ -1356,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Fehler beim Erzeugen des Gruppen-Links"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Fehler beim Anlegen eines Mitglied-Kontaktes"; + /* No comment provided by engineer. */ "Error creating profile!" = "Fehler beim Erstellen des Profils!"; @@ -1434,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Fehler beim Senden der eMail"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Fehler beim Senden einer Mitglied-Kontakt-Einladung"; + /* No comment provided by engineer. */ "Error sending message" = "Fehler beim Senden der Nachricht"; @@ -2127,6 +2136,9 @@ /* No comment provided by engineer. */ "New database archive" = "Neues Datenbankarchiv"; +/* No comment provided by engineer. */ +"New desktop app!" = "Neue Desktop-App!"; + /* No comment provided by engineer. */ "New display name" = "Neuer Anzeigename"; @@ -2203,7 +2215,8 @@ "observer" = "Beobachter"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "Aus"; /* No comment provided by engineer. */ @@ -2284,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Nur Ihr Kontakt kann Sprachnachrichten versenden."; +/* No comment provided by engineer. */ +"Open" = "Öffnen"; + /* No comment provided by engineer. */ "Open chat" = "Chat öffnen"; @@ -2302,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Öffne Datenbank …"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Das Öffnen des Links über den Browser kann die Privatsphäre und Sicherheit der Verbindung reduzieren. SimpleX-Links, denen nicht vertraut wird, werden Rot sein."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "oder chatten Sie mit den Entwicklern"; - /* member role */ "owner" = "Eigentümer"; @@ -2758,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Empfangsbestätigungen senden an"; +/* No comment provided by engineer. */ +"send direct message" = "Direktnachricht senden"; + /* No comment provided by engineer. */ "Send direct message" = "Direktnachricht senden"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Eine Direktnachricht zum Verbinden senden"; + /* No comment provided by engineer. */ "Send disappearing message" = "Verschwindende Nachricht senden"; @@ -2941,6 +2957,9 @@ /* simplex link type */ "SimpleX one-time invitation" = "SimpleX-Einmal-Einladung"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "Vereinfachter Inkognito-Modus"; + /* No comment provided by engineer. */ "Skip" = "Überspringen"; @@ -3088,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Die Verschlüsselung funktioniert und ein neues Verschlüsselungsabkommen ist nicht erforderlich. Es kann zu Verbindungsfehlern kommen!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Die Gruppe ist vollständig dezentralisiert – sie ist nur für Mitglieder sichtbar."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Der Hash der vorherigen Nachricht unterscheidet sich."; @@ -3187,6 +3203,9 @@ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Um die Ende-zu-Ende-Verschlüsselung mit Ihrem Kontakt zu überprüfen, müssen Sie den Sicherheitscode in Ihren Apps vergleichen oder scannen."; +/* No comment provided by engineer. */ +"Toggle incognito when connecting." = "Inkognito beim Verbinden einschalten."; + /* No comment provided by engineer. */ "Transport isolation" = "Transport-Isolation"; @@ -3580,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Sie müssen sich authentifizieren, wenn Sie die im Hintergrund befindliche App nach 30 Sekunden starten oder fortsetzen."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Sie werden der Gruppe beitreten, auf die sich dieser Link bezieht und sich mit deren Gruppenmitgliedern verbinden."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind."; @@ -3613,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Ihre Chat-Datenbank ist nicht verschlüsselt. Bitte legen Sie ein Passwort fest, um sie zu schützen."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Ihr Chat-Profil wird an Gruppenmitglieder gesendet"; - /* No comment provided by engineer. */ "Your chat profiles" = "Meine Chat-Profile"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index c4180ea153..d9b14eddbd 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_italic_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- conexión al [servicio de directorio](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- confirmaciones de entrega (hasta 20 miembros).\n- mayor rapidez y estabilidad."; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- entrega de mensajes más estable.\n- grupos un poco mejores.\n- ¡y más!"; @@ -181,6 +184,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld minutos"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%lld idiomas de interfaz nuevos"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld segundo(s)"; @@ -209,10 +215,10 @@ "%lldw" = "%lldw"; /* No comment provided by engineer. */ -"%u messages failed to decrypt." = "%u mensajes no pudieron ser descifrados."; +"%u messages failed to decrypt." = "%u mensaje(s) no ha(n) podido ser descifrado(s)."; /* No comment provided by engineer. */ -"%u messages skipped." = "%u mensajes omitidos."; +"%u messages skipped." = "%u mensaje(s) omitido(s)."; /* No comment provided by engineer. */ "`a + b`" = "\\`a + b`"; @@ -443,6 +449,9 @@ /* No comment provided by engineer. */ "App build: %@" = "Compilación app: %@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "Cifrado de los nuevos archivos locales (excepto vídeos)."; + /* No comment provided by engineer. */ "App icon" = "Icono aplicación"; @@ -536,6 +545,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Tanto tú como tu contacto podéis enviar mensajes de voz."; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Búlgaro, Finlandés, Tailandés y Ucraniano - gracias a los usuarios y [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Mediante perfil (por defecto) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -708,21 +720,12 @@ /* server test step */ "Connect" = "Conectar"; -/* No comment provided by engineer. */ -"Connect directly" = "Conectar directamente"; - /* No comment provided by engineer. */ "Connect incognito" = "Conectar incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "contacta con los desarrolladores de SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Conectar mediante enlace de contacto"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "¿Conectar mediante enlace de grupo?"; - /* No comment provided by engineer. */ "Connect via link" = "Conectar mediante enlace"; @@ -735,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "conectado"; +/* rcv group event chat item */ +"connected directly" = "conectado directamente"; + /* No comment provided by engineer. */ "connecting" = "conectando"; @@ -789,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "El contácto ya existe"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "El contacto y todos los mensajes serán eliminados. ¡No podrá deshacerse!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "el contacto dispone de cifrado de extremo a extremo"; @@ -843,6 +846,9 @@ /* No comment provided by engineer. */ "Create link" = "Crear enlace"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Crea perfil nuevo en la [aplicación para PC](https://simplex.Descargas/de chat/). 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "Crea enlace de invitación de un uso"; @@ -993,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Eliminar contacto"; -/* No comment provided by engineer. */ -"Delete contact?" = "Eliminar contacto?"; - /* No comment provided by engineer. */ "Delete database" = "Eliminar base de datos"; @@ -1150,10 +1153,7 @@ "Disconnect" = "Desconectar"; /* No comment provided by engineer. */ -"Display name" = "Nombre mostrado"; - -/* No comment provided by engineer. */ -"Display name:" = "Nombre mostrado:"; +"Discover and join groups" = "Descubre y únete a grupos"; /* No comment provided by engineer. */ "Do it later" = "Hacer más tarde"; @@ -1245,6 +1245,12 @@ /* No comment provided by engineer. */ "Encrypt database?" = "¿Cifrar base de datos?"; +/* No comment provided by engineer. */ +"Encrypt local files" = "Cifra archivos locales"; + +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "Cifra archivos almacenados y multimedia"; + /* No comment provided by engineer. */ "Encrypted database" = "Base de datos cifrada"; @@ -1353,9 +1359,15 @@ /* No comment provided by engineer. */ "Error creating group link" = "Error al crear enlace de grupo"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Error al establecer contacto con el miembro"; + /* No comment provided by engineer. */ "Error creating profile!" = "¡Error al crear perfil!"; +/* No comment provided by engineer. */ +"Error decrypting file" = "Error al descifrar el archivo"; + /* No comment provided by engineer. */ "Error deleting chat database" = "Error al eliminar base de datos"; @@ -1428,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Error al enviar email"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Error al enviar mensaje de invitación al contacto"; + /* No comment provided by engineer. */ "Error sending message" = "Error al enviar mensaje"; @@ -1645,10 +1660,10 @@ "Group welcome message" = "Mensaje de bienvenida en grupos"; /* No comment provided by engineer. */ -"Group will be deleted for all members - this cannot be undone!" = "El grupo se eliminará para todos los miembros. ¡No podrá deshacerse!"; +"Group will be deleted for all members - this cannot be undone!" = "El grupo será eliminado para todos los miembros. ¡No podrá deshacerse!"; /* No comment provided by engineer. */ -"Group will be deleted for you - this cannot be undone!" = "El grupo se eliminará para tí. ¡No podrá deshacerse!"; +"Group will be deleted for you - this cannot be undone!" = "El grupo será eliminado para tí. ¡No podrá deshacerse!"; /* No comment provided by engineer. */ "Help" = "Ayuda"; @@ -2121,6 +2136,9 @@ /* No comment provided by engineer. */ "New database archive" = "Nuevo archivo de bases de datos"; +/* No comment provided by engineer. */ +"New desktop app!" = "Nueva aplicación para PC!"; + /* No comment provided by engineer. */ "New display name" = "Nuevo nombre mostrado"; @@ -2197,7 +2215,8 @@ "observer" = "observador"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "desactivado"; /* No comment provided by engineer. */ @@ -2278,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Sólo tu contacto puede enviar mensajes de voz."; +/* No comment provided by engineer. */ +"Open" = "Abrir"; + /* No comment provided by engineer. */ "Open chat" = "Abrir chat"; @@ -2296,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Abriendo base de datos…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces SimpleX que no son de confianza aparecerán en rojo."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "o contacta mediante Chat con los desarrolladores"; - /* member role */ "owner" = "propietario"; @@ -2752,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Enviar confirmaciones de entrega a"; +/* No comment provided by engineer. */ +"send direct message" = "Enviar mensaje directo"; + /* No comment provided by engineer. */ "Send direct message" = "Enviar mensaje directo"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Enviar mensaje directo para conectar"; + /* No comment provided by engineer. */ "Send disappearing message" = "Enviar mensaje temporal"; @@ -2935,6 +2957,9 @@ /* simplex link type */ "SimpleX one-time invitation" = "Invitación SimpleX de un uso"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "Modo incógnito simplificado"; + /* No comment provided by engineer. */ "Skip" = "Omitir"; @@ -3082,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "El cifrado funciona y un cifrado nuevo no es necesario. ¡Podría dar lugar a errores de conexión!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "El grupo está totalmente descentralizado y sólo es visible para los miembros."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "El hash del mensaje anterior es diferente."; @@ -3181,6 +3203,9 @@ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Para comprobar el cifrado de extremo a extremo con tu contacto compara (o escanea) el código en tus dispositivos."; +/* No comment provided by engineer. */ +"Toggle incognito when connecting." = "Activa incógnito al conectar."; + /* No comment provided by engineer. */ "Transport isolation" = "Aislamiento de transporte"; @@ -3574,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Se te pedirá identificarte cuándo inicies o continues usando la aplicación tras 30 segundos en segundo plano."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Te unirás al grupo al que hace referencia este enlace y te conectarás con sus miembros."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Seguirás recibiendo llamadas y notificaciones de los perfiles silenciados cuando estén activos."; @@ -3607,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "La base de datos no está cifrada - establece una contraseña para cifrarla."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Tu perfil será enviado a los miembros del grupo"; - /* No comment provided by engineer. */ "Your chat profiles" = "Mis perfiles"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index a917c4a0b4..fad3a54b8b 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -181,6 +181,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld minuuttia"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%lld uutta käyttöliittymän kieltä"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld sekunti(a)"; @@ -708,21 +711,12 @@ /* server test step */ "Connect" = "Yhdistä"; -/* No comment provided by engineer. */ -"Connect directly" = "Yhdistä suoraan"; - /* No comment provided by engineer. */ "Connect incognito" = "Yhdistä Incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "ole yhteydessä SimpleX Chat -kehittäjiin."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Yhdistä kontaktilinkillä"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Yhdistetäänkö ryhmälinkin kautta?"; - /* No comment provided by engineer. */ "Connect via link" = "Yhdistä linkin kautta"; @@ -789,9 +783,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Kontakti on jo olemassa"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Kontakti ja kaikki viestit poistetaan - tätä ei voi perua!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "kontaktilla on e2e-salaus"; @@ -843,6 +834,9 @@ /* No comment provided by engineer. */ "Create link" = "Luo linkki"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Luo uusi profiili [työpöytäsovelluksessa](https://simplex.chat/downloads/). 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "Luo kertakutsulinkki"; @@ -993,9 +987,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Poista kontakti"; -/* No comment provided by engineer. */ -"Delete contact?" = "Poista kontakti?"; - /* No comment provided by engineer. */ "Delete database" = "Poista tietokanta"; @@ -1150,10 +1141,7 @@ "Disconnect" = "Katkaise"; /* No comment provided by engineer. */ -"Display name" = "Näyttönimi"; - -/* No comment provided by engineer. */ -"Display name:" = "Näyttönimi:"; +"Discover and join groups" = "Löydä ryhmiä ja liity niihin"; /* No comment provided by engineer. */ "Do it later" = "Tee myöhemmin"; @@ -2203,7 +2191,8 @@ "observer" = "tarkkailija"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "pois"; /* No comment provided by engineer. */ @@ -2302,12 +2291,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Avataan tietokantaa…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Linkin avaaminen selaimessa voi heikentää yhteyden yksityisyyttä ja turvallisuutta. Epäluotetut SimpleX-linkit näkyvät punaisina."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "tai keskustele kehittäjien kanssa"; - /* member role */ "owner" = "omistaja"; @@ -3088,9 +3071,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Ryhmä on täysin hajautettu - se näkyy vain jäsenille."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Edellisen viestin tarkiste on erilainen."; @@ -3580,9 +3560,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Sinun on tunnistauduttava, kun käynnistät sovelluksen tai jatkat sen käyttöä 30 sekunnin tauon jälkeen."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Liityt ryhmään, johon tämä linkki viittaa, ja muodostat yhteyden sen ryhmän jäseniin."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Saat edelleen puheluita ja ilmoituksia mykistetyiltä profiileilta, kun ne ovat aktiivisia."; @@ -3613,9 +3590,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Keskustelut-tietokantasi ei ole salattu - aseta tunnuslause sen salaamiseksi."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Keskusteluprofiilisi lähetetään ryhmän jäsenille"; - /* No comment provided by engineer. */ "Your chat profiles" = "Keskusteluprofiilisi"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index f9f7703382..52c114a9de 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Se connecter"; -/* No comment provided by engineer. */ -"Connect directly" = "Se connecter directement"; - /* No comment provided by engineer. */ "Connect incognito" = "Se connecter incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "se connecter aux developpeurs de SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Se connecter via un lien de contact"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Se connecter via le lien du groupe ?"; - /* No comment provided by engineer. */ "Connect via link" = "Se connecter via un lien"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "connecté"; +/* rcv group event chat item */ +"connected directly" = "s'est connecté.e de manière directe"; + /* No comment provided by engineer. */ "connecting" = "connexion"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Contact déjà existant"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Le contact et tous les messages seront supprimés - impossible de revenir en arrière !"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "Ce contact a le chiffrement de bout en bout"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Supprimer le contact"; -/* No comment provided by engineer. */ -"Delete contact?" = "Supprimer le contact ?"; - /* No comment provided by engineer. */ "Delete database" = "Supprimer la base de données"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Découvrir et rejoindre des groupes"; -/* No comment provided by engineer. */ -"Display name" = "Nom affiché"; - -/* No comment provided by engineer. */ -"Display name:" = "Nom affiché :"; - /* No comment provided by engineer. */ "Do it later" = "Faites-le plus tard"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Erreur lors de la création du lien du groupe"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Erreur lors de la création du contact du membre"; + /* No comment provided by engineer. */ "Error creating profile!" = "Erreur lors de la création du profil !"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Erreur lors de l'envoi de l'e-mail"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Erreur lors de l'envoi de l'invitation de contact d'un membre"; + /* No comment provided by engineer. */ "Error sending message" = "Erreur lors de l'envoi du message"; @@ -2227,7 +2215,8 @@ "observer" = "observateur"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "off"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Seul votre contact peut envoyer des messages vocaux."; +/* No comment provided by engineer. */ +"Open" = "Ouvrir"; + /* No comment provided by engineer. */ "Open chat" = "Ouvrir le chat"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Ouverture de la base de données…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "ou ici pour discuter avec les développeurs"; - /* member role */ "owner" = "propriétaire"; @@ -2783,7 +2769,13 @@ "Send delivery receipts to" = "Envoyer les accusés de réception à"; /* No comment provided by engineer. */ -"Send direct message" = "Envoi de message direct"; +"send direct message" = "envoyer un message direct"; + +/* No comment provided by engineer. */ +"Send direct message" = "Envoyer un message direct"; + +/* No comment provided by engineer. */ +"Send direct message to connect" = "Envoyer un message direct pour vous connecter"; /* No comment provided by engineer. */ "Send disappearing message" = "Envoyer un message éphémère"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Le chiffrement fonctionne et le nouvel accord de chiffrement n'est pas nécessaire. Cela peut provoquer des erreurs de connexion !"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Le groupe est entièrement décentralisé – il n'est visible que par ses membres."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Le hash du message précédent est différent."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l'application après 30 secondes en arrière-plan."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Vous continuerez à recevoir des appels et des notifications des profils mis en sourdine lorsqu'ils sont actifs."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Votre base de données de chat n'est pas chiffrée - définisez une phrase secrète."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Votre profil de chat sera envoyé aux membres du groupe"; - /* No comment provided by engineer. */ "Your chat profiles" = "Vos profils de chat"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index a9a663dfdf..ac2cfc4964 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Connetti"; -/* No comment provided by engineer. */ -"Connect directly" = "Connetti direttamente"; - /* No comment provided by engineer. */ "Connect incognito" = "Connetti in incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "connettiti agli sviluppatori di SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Connetti via link del contatto"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Connettere via link del gruppo?"; - /* No comment provided by engineer. */ "Connect via link" = "Connetti via link"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "connesso/a"; +/* rcv group event chat item */ +"connected directly" = "si è connesso/a direttamente"; + /* No comment provided by engineer. */ "connecting" = "in connessione"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Il contatto esiste già"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Il contatto e tutti i messaggi verranno eliminati, non è reversibile!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "il contatto ha la crittografia e2e"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Elimina contatto"; -/* No comment provided by engineer. */ -"Delete contact?" = "Eliminare il contatto?"; - /* No comment provided by engineer. */ "Delete database" = "Elimina database"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Scopri ed unisciti ai gruppi"; -/* No comment provided by engineer. */ -"Display name" = "Nome da mostrare"; - -/* No comment provided by engineer. */ -"Display name:" = "Nome da mostrare:"; - /* No comment provided by engineer. */ "Do it later" = "Fallo dopo"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Errore nella creazione del link del gruppo"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Errore di creazione del contatto"; + /* No comment provided by engineer. */ "Error creating profile!" = "Errore nella creazione del profilo!"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Errore nell'invio dell'email"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Errore di invio dell'invito al contatto"; + /* No comment provided by engineer. */ "Error sending message" = "Errore nell'invio del messaggio"; @@ -2026,7 +2014,7 @@ "Member" = "Membro"; /* rcv group event chat item */ -"member connected" = "è connesso/a"; +"member connected" = "si è connesso/a"; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Il ruolo del membro verrà cambiato in \"%@\". Tutti i membri del gruppo verranno avvisati."; @@ -2227,7 +2215,8 @@ "observer" = "osservatore"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "off"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Solo il tuo contatto può inviare messaggi vocali."; +/* No comment provided by engineer. */ +"Open" = "Apri"; + /* No comment provided by engineer. */ "Open chat" = "Apri chat"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Apertura del database…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Aprire il link nel browser può ridurre la privacy e la sicurezza della connessione. I link SimpleX non fidati saranno in rosso."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "o scrivi agli sviluppatori"; - /* member role */ "owner" = "proprietario"; @@ -2600,13 +2586,13 @@ "Remove passphrase from keychain?" = "Rimuovere la password dal portachiavi?"; /* No comment provided by engineer. */ -"removed" = "ha rimosso"; +"removed" = "rimosso"; /* rcv group event chat item */ "removed %@" = "ha rimosso %@"; /* rcv group event chat item */ -"removed you" = "sei stato/a rimosso/a"; +"removed you" = "ti ha rimosso/a"; /* No comment provided by engineer. */ "Renegotiate" = "Rinegoziare"; @@ -2654,7 +2640,7 @@ "Reveal" = "Rivela"; /* No comment provided by engineer. */ -"Revert" = "Annulla"; +"Revert" = "Ripristina"; /* No comment provided by engineer. */ "Revoke" = "Revoca"; @@ -2782,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Invia ricevute di consegna a"; +/* No comment provided by engineer. */ +"send direct message" = "invia messaggio diretto"; + /* No comment provided by engineer. */ "Send direct message" = "Invia messaggio diretto"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Invia messaggio diretto per connetterti"; + /* No comment provided by engineer. */ "Send disappearing message" = "Invia messaggio a tempo"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "La crittografia funziona e il nuovo accordo sulla crittografia non è richiesto. Potrebbero verificarsi errori di connessione!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Il gruppo è completamente decentralizzato: è visibile solo ai membri."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "L'hash del messaggio precedente è diverso."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Dovrai autenticarti quando avvii o riapri l'app dopo 30 secondi in secondo piano."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Entrerai in un gruppo a cui si riferisce questo link e ti connetterai ai suoi membri."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Continuerai a ricevere chiamate e notifiche da profili silenziati quando sono attivi."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Il tuo database della chat non è crittografato: imposta la password per crittografarlo."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Il tuo profilo di chat verrà inviato ai membri del gruppo"; - /* No comment provided by engineer. */ "Your chat profiles" = "I tuoi profili di chat"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 6fadf87590..ff97d71daf 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -181,6 +181,9 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld 分"; +/* No comment provided by engineer. */ +"%lld new interface languages" = "%lldつの新しいインターフェース言語"; + /* No comment provided by engineer. */ "%lld second(s)" = "%lld 秒"; @@ -443,6 +446,9 @@ /* No comment provided by engineer. */ "App build: %@" = "アプリのビルド: %@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "アプリは新しいローカルファイル(ビデオを除く)を暗号化します。"; + /* No comment provided by engineer. */ "App icon" = "アプリのアイコン"; @@ -536,6 +542,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "あなたと連絡相手が音声メッセージを送信できます。"; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "ブルガリア語、フィンランド語、タイ語、ウクライナ語 - ユーザーと [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)に感謝します!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "チャット プロファイル経由 (デフォルト) または [接続経由](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -708,21 +717,12 @@ /* server test step */ "Connect" = "接続"; -/* No comment provided by engineer. */ -"Connect directly" = "直接接続する"; - /* No comment provided by engineer. */ "Connect incognito" = "シークレットモードで接続"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "SimpleX Chat 開発者に接続します。"; -/* No comment provided by engineer. */ -"Connect via contact link" = "連絡先リンク経由で接続しますか?"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "グループリンク経由で接続しますか?"; - /* No comment provided by engineer. */ "Connect via link" = "リンク経由で接続"; @@ -789,9 +789,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "連絡先に既に存在します"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "連絡先と全メッセージが削除されます (※元に戻せません※)!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "連絡先はエンドツーエンド暗号化があります"; @@ -843,6 +840,9 @@ /* No comment provided by engineer. */ "Create link" = "リンクを生成する"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "[デスクトップアプリ](https://simplex.chat/downloads/)で新しいプロファイルを作成します。 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "使い捨ての招待リンクを生成する"; @@ -993,9 +993,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "連絡先を削除"; -/* No comment provided by engineer. */ -"Delete contact?" = "連絡先を削除しますか?"; - /* No comment provided by engineer. */ "Delete database" = "データベースを削除"; @@ -1080,6 +1077,9 @@ /* No comment provided by engineer. */ "Delivery receipts are disabled!" = "Delivery receipts are disabled!"; +/* No comment provided by engineer. */ +"Delivery receipts!" = "配信通知!"; + /* No comment provided by engineer. */ "Description" = "説明"; @@ -1147,10 +1147,7 @@ "Disconnect" = "切断"; /* No comment provided by engineer. */ -"Display name" = "表示名"; - -/* No comment provided by engineer. */ -"Display name:" = "表示名:"; +"Discover and join groups" = "グループを見つけて参加する"; /* No comment provided by engineer. */ "Do it later" = "後で行う"; @@ -1245,6 +1242,9 @@ /* No comment provided by engineer. */ "Encrypt local files" = "ローカルファイルを暗号化する"; +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "保存されたファイルとメディアを暗号化する"; + /* No comment provided by engineer. */ "Encrypted database" = "暗号化済みデータベース"; @@ -1353,6 +1353,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "グループリンク生成にエラー発生"; +/* No comment provided by engineer. */ +"Error creating member contact" = "メンバー連絡先の作成中にエラーが発生"; + /* No comment provided by engineer. */ "Error creating profile!" = "プロフィール作成にエラー発生!"; @@ -1428,6 +1431,9 @@ /* No comment provided by engineer. */ "Error sending email" = "メールの送信にエラー発生"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "招待メッセージの送信エラー"; + /* No comment provided by engineer. */ "Error sending message" = "メッセージ送信にエラー発生"; @@ -2115,6 +2121,9 @@ /* No comment provided by engineer. */ "New database archive" = "新しいデータベースのアーカイブ"; +/* No comment provided by engineer. */ +"New desktop app!" = "新しいデスクトップアプリ!"; + /* No comment provided by engineer. */ "New display name" = "新たな表示名"; @@ -2191,7 +2200,8 @@ "observer" = "オブザーバー"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "オフ"; /* No comment provided by engineer. */ @@ -2272,6 +2282,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "音声メッセージを送れるのはあなたの連絡相手だけです。"; +/* No comment provided by engineer. */ +"Open" = "開く"; + /* No comment provided by engineer. */ "Open chat" = "チャットを開く"; @@ -2290,12 +2303,6 @@ /* No comment provided by engineer. */ "Opening database…" = "データベースを開いています…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "ブラウザでリンクを開くと接続のプライバシーとセキュリティが下がる可能性があります。信頼されないSimpleXリンクは読み込まれません。"; - -/* No comment provided by engineer. */ -"or chat with the developers" = "または開発者とチャットする"; - /* member role */ "owner" = "オーナー"; @@ -2743,6 +2750,9 @@ /* No comment provided by engineer. */ "Send direct message" = "ダイレクトメッセージを送信"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "ダイレクトメッセージを送信して接続する"; + /* No comment provided by engineer. */ "Send disappearing message" = "消えるメッセージを送信"; @@ -2902,6 +2912,9 @@ /* simplex link type */ "SimpleX one-time invitation" = "SimpleX使い捨て招待リンク"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "シークレットモードの簡素化"; + /* No comment provided by engineer. */ "Skip" = "スキップ"; @@ -3049,9 +3062,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "暗号化は機能しており、新しい暗号化への同意は必要ありません。接続エラーが発生する可能性があります!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "グループは完全分散型で、メンバーしか内容を見れません。"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "以前のメッセージとハッシュ値が異なります。"; @@ -3538,9 +3548,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "起動時、または非アクティブ状態で30秒が経った後に戻ると、認証する必要となります。"; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "このリンクのグループに参加し、そのメンバーに繋がります。"; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "ミュートされたプロフィールがアクティブな場合でも、そのプロフィールからの通話や通知は引き続き受信します。"; @@ -3571,9 +3578,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "チャット データベースは暗号化されていません - 暗号化するにはパスフレーズを設定してください。"; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "あなたのチャットプロフィールが他のグループメンバーに送られます"; - /* No comment provided by engineer. */ "Your chat profiles" = "あなたのチャットプロフィール"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index a218eeeff3..5c53c0c34e 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Verbind"; -/* No comment provided by engineer. */ -"Connect directly" = "Verbind direct"; - /* No comment provided by engineer. */ "Connect incognito" = "Verbind incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "maak verbinding met SimpleX Chat-ontwikkelaars."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Verbinden via contact link?"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Verbinden via groep link?"; - /* No comment provided by engineer. */ "Connect via link" = "Maak verbinding via link"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "verbonden"; +/* rcv group event chat item */ +"connected directly" = "direct verbonden"; + /* No comment provided by engineer. */ "connecting" = "Verbinden"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Contact bestaat al"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Contact en alle berichten worden verwijderd, dit kan niet ongedaan worden gemaakt!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "contact heeft e2e-codering"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Verwijder contact"; -/* No comment provided by engineer. */ -"Delete contact?" = "Verwijder contact?"; - /* No comment provided by engineer. */ "Delete database" = "Database verwijderen"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Ontdek en sluit je aan bij groepen"; -/* No comment provided by engineer. */ -"Display name" = "Weergavenaam"; - -/* No comment provided by engineer. */ -"Display name:" = "Weergavenaam:"; - /* No comment provided by engineer. */ "Do it later" = "Doe het later"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Fout bij maken van groep link"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Fout bij aanmaken contact"; + /* No comment provided by engineer. */ "Error creating profile!" = "Fout bij aanmaken van profiel!"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Fout bij het verzenden van e-mail"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Fout bij verzenden van contact uitnodiging"; + /* No comment provided by engineer. */ "Error sending message" = "Fout bij verzenden van bericht"; @@ -1894,10 +1882,10 @@ "It allows having many anonymous connections without any shared data between them in a single chat profile." = "Het maakt het mogelijk om veel anonieme verbindingen te hebben zonder enige gedeelde gegevens tussen hen in een enkel chat profiel."; /* No comment provided by engineer. */ -"It can happen when you or your connection used the old database backup." = "Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt."; +"It can happen when you or your connection used the old database backup." = "Het kan gebeuren wanneer u of de ander een oude database back-up gebruikt."; /* No comment provided by engineer. */ -"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Het kan gebeuren wanneer:\n1. De berichten zijn na 2 dagen verlopen bij de verzendende client of na 30 dagen op de server.\n2. Decodering van het bericht is mislukt, omdat u of uw contact een oude databaseback-up heeft gebruikt.\n3. De verbinding is verbroken."; +"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Het kan gebeuren wanneer:\n1. De berichten zijn na 2 dagen verlopen bij de verzendende client of na 30 dagen op de server.\n2. Decodering van het bericht is mislukt, omdat u of uw contact een oude database back-up heeft gebruikt.\n3. De verbinding is verbroken."; /* No comment provided by engineer. */ "It seems like you are already connected via this link. If it is not the case, there was an error (%@)." = "Het lijkt erop dat u al bent verbonden via deze link. Als dit niet het geval is, is er een fout opgetreden (%@)."; @@ -2227,7 +2215,8 @@ "observer" = "Waarnemer"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "uit"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Alleen uw contact kan spraak berichten verzenden."; +/* No comment provided by engineer. */ +"Open" = "Open"; + /* No comment provided by engineer. */ "Open chat" = "Gesprekken openen"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Database openen…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Het openen van de link in de browser kan de privacy en beveiliging van de verbinding verminderen. Niet vertrouwde SimpleX links worden rood weergegeven."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "of praat met de ontwikkelaars"; - /* member role */ "owner" = "Eigenaar"; @@ -2782,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Stuur ontvangstbewijzen naar"; +/* No comment provided by engineer. */ +"send direct message" = "stuur een direct bericht"; + /* No comment provided by engineer. */ "Send direct message" = "Direct bericht sturen"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Stuur een direct bericht om verbinding te maken"; + /* No comment provided by engineer. */ "Send disappearing message" = "Stuur een verdwijnend bericht"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "De versleuteling werkt en de nieuwe versleutelingsovereenkomst is niet vereist. Dit kan leiden tot verbindingsfouten!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "De groep is volledig gedecentraliseerd – het is alleen zichtbaar voor de leden."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "De hash van het vorige bericht is anders."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "U moet zich authenticeren wanneer u de app na 30 seconden op de achtergrond start of hervat."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "U wordt lid van de groep waar deze link naar verwijst en maakt verbinding met de groepsleden."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "U ontvangt nog steeds oproepen en meldingen van gedempte profielen wanneer deze actief zijn."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Uw chat database is niet versleuteld, stel een wachtwoord in om deze te versleutelen."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Uw chat profiel wordt verzonden naar de groepsleden"; - /* No comment provided by engineer. */ "Your chat profiles" = "Uw chat profielen"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index d80ff67d1f..1c57568f90 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Połącz"; -/* No comment provided by engineer. */ -"Connect directly" = "Połącz bezpośrednio"; - /* No comment provided by engineer. */ "Connect incognito" = "Połącz incognito"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "połącz się z deweloperami SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Połącz przez link kontaktowy"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Połącz się przez link grupowy?"; - /* No comment provided by engineer. */ "Connect via link" = "Połącz się przez link"; @@ -747,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "połączony"; +/* rcv group event chat item */ +"connected directly" = "połącz bezpośrednio"; + /* No comment provided by engineer. */ "connecting" = "łączenie"; @@ -801,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Kontakt już istnieje"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Kontakt i wszystkie wiadomości zostaną usunięte - nie można tego cofnąć!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "kontakt posiada szyfrowanie e2e"; @@ -1008,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Usuń Kontakt"; -/* No comment provided by engineer. */ -"Delete contact?" = "Usunąć kontakt?"; - /* No comment provided by engineer. */ "Delete database" = "Usuń bazę danych"; @@ -1167,12 +1155,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Odkrywaj i dołączaj do grup"; -/* No comment provided by engineer. */ -"Display name" = "Wyświetlana nazwa"; - -/* No comment provided by engineer. */ -"Display name:" = "Wyświetlana nazwa:"; - /* No comment provided by engineer. */ "Do it later" = "Zrób to później"; @@ -1377,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "Błąd tworzenia linku grupy"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Błąd tworzenia kontaktu członka"; + /* No comment provided by engineer. */ "Error creating profile!" = "Błąd tworzenia profilu!"; @@ -1455,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "Błąd wysyłania e-mail"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Błąd wysyłania zaproszenia kontaktu członka"; + /* No comment provided by engineer. */ "Error sending message" = "Błąd wysyłania wiadomości"; @@ -2227,7 +2215,8 @@ "observer" = "obserwator"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "wyłączony"; /* No comment provided by engineer. */ @@ -2308,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Tylko Twój kontakt może wysyłać wiadomości głosowe."; +/* No comment provided by engineer. */ +"Open" = "Otwórz"; + /* No comment provided by engineer. */ "Open chat" = "Otwórz czat"; @@ -2326,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Otwieranie bazy danych…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Otwarcie łącza w przeglądarce może zmniejszyć prywatność i bezpieczeństwo połączenia. Niezaufane linki SimpleX będą miały kolor czerwony."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "lub porozmawiać z deweloperami"; - /* member role */ "owner" = "właściciel"; @@ -2782,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "Wyślij potwierdzenia dostawy do"; +/* No comment provided by engineer. */ +"send direct message" = "wyślij wiadomość bezpośrednią"; + /* No comment provided by engineer. */ "Send direct message" = "Wyślij wiadomość bezpośrednią"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "Wyślij wiadomość bezpośrednią aby połączyć"; + /* No comment provided by engineer. */ "Send disappearing message" = "Wyślij znikającą wiadomość"; @@ -3115,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Szyfrowanie działa, a nowe uzgodnienie szyfrowania nie jest wymagane. Może to spowodować błędy w połączeniu!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Grupa jest w pełni zdecentralizowana – jest widoczna tylko dla członków."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Hash poprzedniej wiadomości jest inny."; @@ -3610,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Uwierzytelnienie będzie wymagane przy uruchamianiu lub wznawianiu aplikacji po 30 sekundach w tle."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Dołączysz do grupy, do której odnosi się ten link i połączysz się z jej członkami."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Nadal będziesz otrzymywać połączenia i powiadomienia z wyciszonych profili, gdy są one aktywne."; @@ -3643,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Baza danych czatu nie jest szyfrowana - ustaw hasło, aby ją zaszyfrować."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Twój profil czatu zostanie wysłany do członków grupy"; - /* No comment provided by engineer. */ "Your chat profiles" = "Twoje profile czatu"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 7857870472..73f06afeee 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -720,21 +720,12 @@ /* server test step */ "Connect" = "Соединиться"; -/* No comment provided by engineer. */ -"Connect directly" = "Соединиться напрямую"; - /* No comment provided by engineer. */ "Connect incognito" = "Соединиться Инкогнито"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "соединитесь с разработчиками."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Соединиться через ссылку-контакт"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Соединиться через ссылку группы?"; - /* No comment provided by engineer. */ "Connect via link" = "Соединиться через ссылку"; @@ -801,9 +792,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Существующий контакт"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Контакт и все сообщения будут удалены - это действие нельзя отменить!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "у контакта есть e2e шифрование"; @@ -1008,9 +996,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Удалить контакт"; -/* No comment provided by engineer. */ -"Delete contact?" = "Удалить контакт?"; - /* No comment provided by engineer. */ "Delete database" = "Удалить данные чата"; @@ -1167,12 +1152,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Найдите и вступите в группы"; -/* No comment provided by engineer. */ -"Display name" = "Имя профиля"; - -/* No comment provided by engineer. */ -"Display name:" = "Имя профиля:"; - /* No comment provided by engineer. */ "Do it later" = "Отложить"; @@ -2227,7 +2206,8 @@ "observer" = "читатель"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "нет"; /* No comment provided by engineer. */ @@ -2326,12 +2306,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Открытие базы данных…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "или соединитесь с разработчиками"; - /* member role */ "owner" = "владелец"; @@ -3115,9 +3089,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Группа полностью децентрализована — она видна только членам."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Хэш предыдущего сообщения отличается."; @@ -3610,9 +3581,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Вы вступите в группу, на которую ссылается эта ссылка, и соединитесь с её членами."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Вы все равно получите звонки и уведомления в профилях без звука, когда они активные."; @@ -3643,9 +3611,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Ваш профиль чата будет отправлен членам группы"; - /* No comment provided by engineer. */ "Your chat profiles" = "Ваши профили чата"; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index d886ece0d6..eb47218422 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -690,9 +690,6 @@ /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "เชื่อมต่อกับนักพัฒนา SimpleX Chat"; -/* No comment provided by engineer. */ -"Connect via group link?" = "เชื่อมต่อผ่านลิงค์กลุ่ม?"; - /* No comment provided by engineer. */ "Connect via link" = "เชื่อมต่อผ่านลิงก์"; @@ -756,9 +753,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "ผู้ติดต่อรายนี้มีอยู่แล้ว"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "ผู้ติดต่อและข้อความทั้งหมดจะถูกลบ - ไม่สามารถยกเลิกได้!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "ผู้ติดต่อมีการ encrypt จากต้นจนจบ"; @@ -960,9 +954,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "ลบผู้ติดต่อ"; -/* No comment provided by engineer. */ -"Delete contact?" = "ลบผู้ติดต่อ?"; - /* No comment provided by engineer. */ "Delete database" = "ลบฐานข้อมูล"; @@ -1110,12 +1101,6 @@ /* server test step */ "Disconnect" = "ตัดการเชื่อมต่อ"; -/* No comment provided by engineer. */ -"Display name" = "ชื่อที่แสดง"; - -/* No comment provided by engineer. */ -"Display name:" = "ชื่อที่แสดง:"; - /* No comment provided by engineer. */ "Do it later" = "ทำในภายหลัง"; @@ -2143,7 +2128,8 @@ "observer" = "ผู้สังเกตการณ์"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "ปิด"; /* No comment provided by engineer. */ @@ -2242,12 +2228,6 @@ /* No comment provided by engineer. */ "Opening database…" = "กำลังเปิดฐานข้อมูล…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "การเปิดลิงก์ในเบราว์เซอร์อาจลดความเป็นส่วนตัวและความปลอดภัยของการเชื่อมต่อ ลิงก์ SimpleX ที่ไม่น่าเชื่อถือจะเป็นสีแดง"; - -/* No comment provided by engineer. */ -"or chat with the developers" = "หรือแชทกับนักพัฒนาแอป"; - /* member role */ "owner" = "เจ้าของ"; @@ -3007,9 +2987,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "encryption กำลังทำงานและไม่จำเป็นต้องใช้ข้อตกลง encryption ใหม่ อาจทำให้การเชื่อมต่อผิดพลาดได้!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "กลุ่มมีการกระจายอำนาจอย่างเต็มที่ – มองเห็นได้เฉพาะสมาชิกเท่านั้น"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "แฮชของข้อความก่อนหน้านี้แตกต่างกัน"; @@ -3484,9 +3461,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "คุณจะต้องตรวจสอบสิทธิ์เมื่อคุณเริ่มหรือกลับมาใช้แอปพลิเคชันอีกครั้งหลังจากผ่านไป 30 วินาทีในพื้นหลัง"; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "คุณจะเข้าร่วมกลุ่มที่ลิงก์นี้อ้างถึงและเชื่อมต่อกับสมาชิกในกลุ่ม"; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "คุณจะยังได้รับสายเรียกเข้าและการแจ้งเตือนจากโปรไฟล์ที่ปิดเสียงเมื่อโปรไฟล์ของเขามีการใช้งาน"; @@ -3517,9 +3491,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "ฐานข้อมูลการแชทของคุณไม่ได้ถูก encrypt - ตั้งรหัสผ่านเพื่อ encrypt"; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "โปรไฟล์การแชทของคุณจะถูกส่งไปยังสมาชิกในกลุ่ม"; - /* No comment provided by engineer. */ "Your chat profiles" = "โปรไฟล์แชทของคุณ"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index a9213527c6..aabd1a7101 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -708,21 +708,12 @@ /* server test step */ "Connect" = "Підключіться"; -/* No comment provided by engineer. */ -"Connect directly" = "Підключіться безпосередньо"; - /* No comment provided by engineer. */ "Connect incognito" = "Підключайтеся інкогніто"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "зв'язатися з розробниками SimpleX Chat."; -/* No comment provided by engineer. */ -"Connect via contact link" = "Підключіться за контактним посиланням"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "Підключитися за груповим посиланням?"; - /* No comment provided by engineer. */ "Connect via link" = "Підключіться за посиланням"; @@ -789,9 +780,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "Контакт вже існує"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "Контакт і всі повідомлення будуть видалені - це неможливо скасувати!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "контакт має шифрування e2e"; @@ -993,9 +981,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "Видалити контакт"; -/* No comment provided by engineer. */ -"Delete contact?" = "Видалити контакт?"; - /* No comment provided by engineer. */ "Delete database" = "Видалити базу даних"; @@ -1149,12 +1134,6 @@ /* server test step */ "Disconnect" = "Від'єднати"; -/* No comment provided by engineer. */ -"Display name" = "Відображуване ім'я"; - -/* No comment provided by engineer. */ -"Display name:" = "Відображуване ім'я:"; - /* No comment provided by engineer. */ "Do it later" = "Зробіть це пізніше"; @@ -2197,7 +2176,8 @@ "observer" = "спостерігач"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "вимкнено"; /* No comment provided by engineer. */ @@ -2296,12 +2276,6 @@ /* No comment provided by engineer. */ "Opening database…" = "Відкриття бази даних…"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "Відкриття посилання в браузері може знизити конфіденційність і безпеку з'єднання. Ненадійні посилання SimpleX будуть червоного кольору."; - -/* No comment provided by engineer. */ -"or chat with the developers" = "або поспілкуйтеся з розробниками"; - /* member role */ "owner" = "власник"; @@ -3082,9 +3056,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Шифрування працює і нова угода про шифрування не потрібна. Це може призвести до помилок з'єднання!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "Група повністю децентралізована - її бачать лише учасники."; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "Хеш попереднього повідомлення відрізняється."; @@ -3574,9 +3545,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вам потрібно буде пройти автентифікацію при запуску або відновленні програми після 30 секунд роботи у фоновому режимі."; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "Ви приєднаєтеся до групи, на яку посилається це посилання, і з'єднаєтеся з її учасниками."; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Ви все одно отримуватимете дзвінки та сповіщення від вимкнених профілів, якщо вони активні."; @@ -3607,9 +3575,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "Ваша база даних чату не зашифрована - встановіть ключову фразу, щоб зашифрувати її."; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "Ваш профіль у чаті буде надіслано учасникам групи"; - /* No comment provided by engineer. */ "Your chat profiles" = "Ваші профілі чату"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 25eadf44d4..c106e27f23 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* No comment provided by engineer. */ "_italic_" = "\\_斜体_"; +/* No comment provided by engineer. */ +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- 连接 [目录服务](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- 发送回执(最多20成员)。\n- 更快并且更稳固。"; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- 更稳定的传输!\n- 更好的社群!\n- 以及更多!"; @@ -446,6 +449,9 @@ /* No comment provided by engineer. */ "App build: %@" = "应用程序构建:%@"; +/* No comment provided by engineer. */ +"App encrypts new local files (except videos)." = "应用程序为新的本地文件(视频除外)加密。"; + /* No comment provided by engineer. */ "App icon" = "应用程序图标"; @@ -539,6 +545,9 @@ /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "您和您的联系人都可以发送语音消息。"; +/* No comment provided by engineer. */ +"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "保加利亚语、芬兰语、泰语和乌克兰语——感谢用户和[Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。"; @@ -711,21 +720,12 @@ /* server test step */ "Connect" = "连接"; -/* No comment provided by engineer. */ -"Connect directly" = "直接连接"; - /* No comment provided by engineer. */ "Connect incognito" = "在隐身状态下连接"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "连接到 SimpleX Chat 开发者。"; -/* No comment provided by engineer. */ -"Connect via contact link" = "通过联系人链接进行连接"; - -/* No comment provided by engineer. */ -"Connect via group link?" = "通过群组链接连接?"; - /* No comment provided by engineer. */ "Connect via link" = "通过链接连接"; @@ -738,6 +738,9 @@ /* No comment provided by engineer. */ "connected" = "已连接"; +/* rcv group event chat item */ +"connected directly" = "已直连"; + /* No comment provided by engineer. */ "connecting" = "连接中"; @@ -792,9 +795,6 @@ /* No comment provided by engineer. */ "Contact already exists" = "联系人已存在"; -/* No comment provided by engineer. */ -"Contact and all messages will be deleted - this cannot be undone!" = "联系人和所有的消息都将被删除——这是不可逆回的!"; - /* No comment provided by engineer. */ "contact has e2e encryption" = "联系人具有端到端加密"; @@ -846,6 +846,9 @@ /* No comment provided by engineer. */ "Create link" = "创建链接"; +/* No comment provided by engineer. */ +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "在[桌面应用程序](https://simplex.chat/downloads/)中创建新的个人资料。 💻"; + /* No comment provided by engineer. */ "Create one-time invitation link" = "创建一次性邀请链接"; @@ -996,9 +999,6 @@ /* No comment provided by engineer. */ "Delete Contact" = "删除联系人"; -/* No comment provided by engineer. */ -"Delete contact?" = "删除联系人?"; - /* No comment provided by engineer. */ "Delete database" = "删除数据库"; @@ -1153,10 +1153,7 @@ "Disconnect" = "断开连接"; /* No comment provided by engineer. */ -"Display name" = "显示名称"; - -/* No comment provided by engineer. */ -"Display name:" = "显示名:"; +"Discover and join groups" = "发现和加入群组"; /* No comment provided by engineer. */ "Do it later" = "稍后再做"; @@ -1251,6 +1248,9 @@ /* No comment provided by engineer. */ "Encrypt local files" = "加密本地文件"; +/* No comment provided by engineer. */ +"Encrypt stored files & media" = "为存储的文件和媒体加密"; + /* No comment provided by engineer. */ "Encrypted database" = "加密数据库"; @@ -1359,6 +1359,9 @@ /* No comment provided by engineer. */ "Error creating group link" = "创建群组链接错误"; +/* No comment provided by engineer. */ +"Error creating member contact" = "创建成员联系人时出错"; + /* No comment provided by engineer. */ "Error creating profile!" = "创建资料错误!"; @@ -1437,6 +1440,9 @@ /* No comment provided by engineer. */ "Error sending email" = "发送电邮错误"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "发送成员联系人邀请错误"; + /* No comment provided by engineer. */ "Error sending message" = "发送消息错误"; @@ -2130,6 +2136,9 @@ /* No comment provided by engineer. */ "New database archive" = "新数据库存档"; +/* No comment provided by engineer. */ +"New desktop app!" = "全新桌面应用!"; + /* No comment provided by engineer. */ "New display name" = "新显示名"; @@ -2206,7 +2215,8 @@ "observer" = "观察者"; /* enabled status - group pref value */ + group pref value + time to disappear */ "off" = "关闭"; /* No comment provided by engineer. */ @@ -2287,6 +2297,9 @@ /* No comment provided by engineer. */ "Only your contact can send voice messages." = "只有您的联系人可以发送语音消息。"; +/* No comment provided by engineer. */ +"Open" = "打开"; + /* No comment provided by engineer. */ "Open chat" = "打开聊天"; @@ -2305,12 +2318,6 @@ /* No comment provided by engineer. */ "Opening database…" = "打开数据库中……"; -/* No comment provided by engineer. */ -"Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." = "在浏览器中打开链接可能会降低连接的隐私和安全性。SimpleX 上不受信任的链接将显示为红色。"; - -/* No comment provided by engineer. */ -"or chat with the developers" = "或与开发者聊天"; - /* member role */ "owner" = "群主"; @@ -2761,9 +2768,15 @@ /* No comment provided by engineer. */ "Send delivery receipts to" = "将送达回执发送给"; +/* No comment provided by engineer. */ +"send direct message" = "发送私信"; + /* No comment provided by engineer. */ "Send direct message" = "发送私信"; +/* No comment provided by engineer. */ +"Send direct message to connect" = "发送私信来连接"; + /* No comment provided by engineer. */ "Send disappearing message" = "发送限时消息中"; @@ -2944,6 +2957,9 @@ /* simplex link type */ "SimpleX one-time invitation" = "SimpleX 一次性邀请"; +/* No comment provided by engineer. */ +"Simplified incognito mode" = "简化的隐身模式"; + /* No comment provided by engineer. */ "Skip" = "跳过"; @@ -3091,9 +3107,6 @@ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "加密正在运行,不需要新的加密协议。这可能会导致连接错误!"; -/* No comment provided by engineer. */ -"The group is fully decentralized – it is visible only to the members." = "该小组是完全分散式的——它只对成员可见。"; - /* No comment provided by engineer. */ "The hash of the previous message is different." = "上一条消息的散列不同。"; @@ -3190,6 +3203,9 @@ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "要与您的联系人验证端到端加密,请比较(或扫描)您设备上的代码。"; +/* No comment provided by engineer. */ +"Toggle incognito when connecting." = "在连接时切换隐身模式。"; + /* No comment provided by engineer. */ "Transport isolation" = "传输隔离"; @@ -3583,9 +3599,6 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "当您启动应用或在应用程序驻留后台超过30 秒后,您将需要进行身份验证。"; -/* No comment provided by engineer. */ -"You will join a group this link refers to and connect to its group members." = "您将加入此链接指向的群组并连接到其群组成员。"; - /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "当静音配置文件处于活动状态时,您仍会收到来自静音配置文件的电话和通知。"; @@ -3616,9 +3629,6 @@ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "您的聊天数据库未加密——设置密码来加密。"; -/* No comment provided by engineer. */ -"Your chat profile will be sent to group members" = "您的聊天资料将被发送给群组成员"; - /* No comment provided by engineer. */ "Your chat profiles" = "您的聊天资料"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 65767087be..23d71515af 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -163,7 +163,7 @@ بحث غير مفعّل غيرت دورك إلى %s - جهات الاتصال المفضلة + تفضيلات جهة الاتصال مفعّل مفعّلة لك يمكن لجهات الاتصال تحديد الرسائل لحذفها؛ ستتمكن من مشاهدتها. @@ -211,7 +211,7 @@ إحباط سيتم إحباط تغيير العنوان. سيتم استخدام عنوان الاستلام القديم. مسح الدردشة؟ - وحدة التحكم الدردشة + وحدة تحكم الدردشة ضبط خوادم ICE الاتصال ملف تعريف الدردشة @@ -287,7 +287,7 @@ مساهمة تغيير الدور أدخل كلمة المرور في البحث - سماح الاتصال + تسمح جهة الاتصال تأكيد جهة الاتصال ليست متصلة بعد! اتصال @@ -317,7 +317,7 @@ تواصل عبر الرابط / رمز QR إنشاء رابط دعوة لمرة واحدة تحقق من عنوان الخادم وحاول مرة أخرى. - مسج التَحَقّق + مسح التَحَقُّق أنشئ عنوانًا للسماح للأشخاص بالتواصل معك. أدخل الخادم يدويًا ملون @@ -397,7 +397,7 @@ خطأ في فك الترميز حذف جهة الاتصال؟ حذف بالنسبة لي - الوقت المخصص + وقت مخصّص لامركزي عبارة مرور قاعدة البيانات عبارة المرور الحالية… @@ -474,7 +474,6 @@ سيتم حذف الملف من الخوادم. سيتم استلام الملف عند اكتمال تحميل جهة الاتصال الخاصة بك. المساعدة - الاسم الكامل (اختياري) الملف: %s إصلاح إصلاح الاتصال @@ -484,7 +483,7 @@ تفضيلات المجموعة سريع ولا تنتظر حتى يصبح المرسل متصلاً بالإنترنت! إخفاء - كيفية استخدامها + كيفية الاستخدام كيف يعمل SimpleX التخفي عبر رابط عنوان جهة الاتصال رمز الحماية غير صحيحة! @@ -696,7 +695,7 @@ خطأ في تصدير قاعدة بيانات الدردشة ستتم إزالة العضو من المجموعة - لا يمكن التراجع عن هذا! اجعل الملف الشخصي خاصًا! - فلترة الدردشات غير المقروءة والمفضلة. + تصفية الدردشات غير المقروءة والمفضلة. البحث عن الدردشات بشكل أسرع تفعيل حتى عندما يتم تعطيله في المحادثة. @@ -761,8 +760,7 @@ متوفرة %s متوفرة %s: %2s سيتم تسليم الإشعارات فقط حتى يتوقف التطبيق! - لا توجد محادثات مفلترة - لا يوجد مسافات + لا توجد محادثات مُصفاة لا توجد ملفات مستلمة أو مرسلة ستتوقف الإشعارات عن العمل حتى تعيد تشغيل التطبيق رمز مرور جديد @@ -802,22 +800,22 @@ عبارة مرور جديدة… يرجى الانتظار كلمة المرور مطلوبة - ألصق الرابط المستلم - فقط أصحاب المجموعة يمكنهم تفعيل الملفات والوسائط. - فقط أصحاب المجموعة يمكنهم تفعيل الرسائل الصوتية. + ألصق الرابط المُستلَم + فقط مالكي المجموعة يمكنهم تفعيل الملفات والوسائط. + فقط مالكي المجموعة يمكنهم تفعيل الرسائل الصوتية. (يخزن فقط بواسطة أعضاء المجموعة) كلمة المرور تم تعيين كلمة المرور! - صاحب - فقط جهة اتصالك يمكنها إرسال رسائل مؤقتة. + المالك + فقط جهة اتصالك يمكنها إرسال رسائل تختفي. جهة اتصالك فقط يمكنها إضافة تفاعلات على الرسالة - فقط أصحاب المجموعة يمكنهم تغيير إعداداتها. + فقط مالكي المجموعة يمكنهم تغيير تفضيلات المجموعة. جهة اتصالك فقط يمكنها حذف الرسائل نهائيا (يمكنك تعليم الرسالة للحذف). أنت فقط يمكنك إرسال رسائل صوتية. افتح لم يتم تغيير كلمة المرور! تم تغيير كلمة المرور - يتم فتح قاعدة البيانات… + جارِ فتح قاعدة البيانات… جهة اتصالك فقط يمكنها إرسال رسائل صوتية. ألصق كلمة المرور غير موجودة في مخزن المفاتيح، يرجى إدخالها يدوياً. قد يحدث هذا إذا قمت باستعادة ملفات التطبيق باستخدام أداة استرجاع بيانات. إذا لم يكن الأمر كذلك، تواصل مع المبرمجين رجاء @@ -825,15 +823,15 @@ فتح الرابط في المتصفح قد يقلل خصوصية وحماية اتصالك. الروابط غير الموثوقة من SimpleX ستكون باللون الأحمر أنت فقط يمكنك إضافة تفاعل على الرسالة. أنت فقط يمكنك حذف الرسائل نهائيا (يمكن للمستلم تعليمها للحذف) - أنت فقط يمكنك إرسال رسائل مؤقتة + أنت فقط يمكنك إرسال رسائل تختفي أنت فقط يمكنك إجراء المكالمات. فقط جهة اتصالك يمكنها إجراء المكالمات. افتح وحدة تحكم الدردشة إدخال كلمة المرور - "قم بفتح SimpleX Chat للرد على المكالمة" + افتح SimpleX Chat للرد على المكالمة بروتوكول وكود مفتوح المصدر - يمكن لأي شخص تشغيل الخوادم. كلمة المرور للإظهار - ند لند + ندّ لِندّ يمكن للناس التواصل معك فقط عبر الرابط الذي تقوم بمشاركته مكالمة في الانتظار تشفير ثنائي الطبقات من بين الطريفين.]]> @@ -863,7 +861,7 @@ حفظ إعدادات القبول التلقائي إعادة تعريف الخصوصية الرجاء الإبلاغ للمطورين. - الخصوصية و الأمان + الخصوصية والأمان إزالة إزالة عبارة المرور من Keystore؟ الرجاء إدخال عبارة المرور الحالية الصحيحة. @@ -908,7 +906,7 @@ حفظ وإشعار جهات الاتصال حفظ وتحديث ملف تعريف المجموعة عدد البينج - استلمت رسالة + رسالة مُستلَمة الواجهة البولندية تشغيل الدردشة استعادة النسخة الاحتياطية لقاعدة البيانات @@ -961,14 +959,14 @@ كلمة مرور التدمير الذاتي إرسال الملفات غير مدعوم بعد قام المرسل بإلغاء إرسال الملف - (امسح أو ألصق) + (امسح أو ألصق من الحافظة) ثانية قد يكون المرسل قد ألغى طلب الاتصال مسح رمز الاستجابة السريعة أرسل لنا بريداً مسح رمز الأمان من تطبيق جهة الاتصال مشاركة رابط ذو استخدام واحد - إرسال الملف سوف يتوقف. + سيتم إيقاف إرسال الملف. إرسال رسالة إرسال إرسال رسالة حية @@ -984,7 +982,7 @@ تم إرساله في: %s %s (الحالي) رسالة مرسلة - تعيين إعدادت المجموهة + عيّن تفضيلات المجموعة عيينها بدلا من توثيق النظام مشاركة إرسال @@ -993,12 +991,12 @@ تعيين يوم واحد ثواني رسالة مرسلة - أرسل رسالة مؤقتة + أرسل رسالة تختفي حفظ كلمة مرور الحساب تدمير ذاتي مسح الكود إرسال أسئلة وأفكار - مشاركة العنوان مع جهات الاتصال + مشاركة العنوان مع جهات الاتصال؟ مشاركة العنوان حفظ رسالة الترحيب؟ حفظ السيرفرات @@ -1011,7 +1009,7 @@ تم إرساله في اختيار إرسال تقارير الاستلام سيتم تفعيله لجميع جهات الاتصال. - إرسال تقارير الاستلام سيتم تفعيله لجميع جهات الاتصال ذات حسابات الدردشة الظاهرة + سيتم تفعيل إرسال تقارير الاستلام لجميع جهات الاتصال ذات حسابات دردشة ظاهرة قائمة انتظار آمنة فشل الإرسال تم الإرسال @@ -1019,8 +1017,8 @@ إرسال رسالة حية - سيتم تحديثها للمستلم مع كتابتك لها تعيين اسم جهة الاتصال الإعدادات - حفظ السيرفرات؟ - مسح رمز الاستجابة السريعة للسيرفر + حفظ الخوادم؟ + مسح رمز QR الخادم رمز الأمان حفظ الإعدادات؟ حفظ الإعدادات؟ @@ -1097,7 +1095,7 @@ النظام السمة لبدء محادثة جديدة - للتحقق من التشفير من طرف إلى طرف مع جهة الاتصال الخاصة بك، قارن (أو امسح) الرمز الموجود على أجهزتك. + للتحقق من التشفير بين الطريفين مع جهة اتصالك، قارن (أو امسح) الرمز الموجود على أجهزتك. المجموعة لامركزية بالكامل - فهي مرئية فقط للأعضاء. النظام فشل الاختبار في الخطوة %s. @@ -1353,7 +1351,7 @@ لا توجد دردشة محددة إرسال الإيصالات مُعطَّلة لـ%d مجموعات مجموعات صغيرة (الحد الأقصى 20) - الاتصال مباشرةً؟ + تواصل مباشرةً؟ سيتم إرسال طلب الاتصال لعضو المجموعة هذا. اتصال متخفي استخدم ملف التعريف الحالي @@ -1361,7 +1359,7 @@ افتح إعدادات التطبيق لا يمكن تشغيل SimpleX في الخلفية. ستتلقى الإشعارات فقط عندما يكون التطبيق قيد التشغيل. سيتم مشاركة ملف تعريف عشوائي جديد. - الصق الرابط الذي تلقيته للتواصل مع جهة الاتصال الخاصة بك… + ألصق الرابط المُستلَم للتواصل مع جهة اتصالك… ستتم مشاركة ملفك الشخصي %1$s. قد يغلق التطبيق بعد دقيقة واحدة في الخلفية. سماح @@ -1399,4 +1397,11 @@ - الاتصال بخدمة الدليل (تجريبي)! \n- إيصالات التسليم (ما يصل إلى 20 عضوا). \n- أسرع وأكثر استقرارًا. + افتح + حدث خطأ أثناء إنشاء جهة اتصال للعضو + أرسل رسالة مباشرة للاتصال + وضع التخفي اصبح أسهل + فعّل وضع التخفي عند الاتصال. + أرسل رسالة مباشرة + متصل مباشرةً \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index fa2782531d..8630e37e86 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1230,7 +1230,7 @@ Error updating group link Error deleting group link Error creating member contact - Sending message contact invitation + Error sending invitation Only group owners can change group preferences. Address Share address diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index 39b7bceee7..3bc721174a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -581,7 +581,6 @@ Изчезващи съобщения Въведи съобщение при посрещане…(незадължително) Съобщение при посрещане - Пълно име (незадължително) Грешка при свързване със сървъра С незадължително съобщение при посрещане. Грешка при изтриване на чат базата данни @@ -815,7 +814,6 @@ Няма се използват Onion хостове. Нека да поговорим в SimpleX Chat Парола за показване - Без интервали! GitHub хранилище.]]> Когато приложението работи Периодично @@ -1400,4 +1398,9 @@ - свържете се с директория за услуги (БЕТА)! \n- потвърждениe за доставка (до 20 члена). \n- по-бързо и по-стабилно. + Отвори + Грешка при създаване на контакт с член + Изпрати лично съобщение за свързване + изпрати лично съобщение + свързан директно \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 2afdb7a690..e733201a74 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -5,7 +5,7 @@ Hlasové zprávy povoleny. Správci mohou vytvářet odkazy pro připojení ke skupinám. Přijmout - Přidejte přednastavené servery + Přidat přednastavené servery Pokročilá nastavení sítě Přijmout Přidat server… @@ -24,7 +24,7 @@ Přidat profil Všechny chaty a zprávy budou smazány – tuto akci nelze vrátit zpět! Povolte mizící zprávy, pouze pokud to váš kontakt povolí. - Přidejte servery skenováním QR kódů. + Přidat servery skenováním QR kódů. 1 měsíci 1 týdnu přijatý hovor @@ -328,7 +328,7 @@ Zabezpečit frontu Okamžitá oznámení! V nastavení ji lze vypnout - oznámení se budou zobrazovat pokud aplikace běží.]]> - vypněte optimalizaci baterie pro SimpleX v dalším dialogu. V opačném případě budou oznámení vypnuta.]]> + povolte pro SimpleX běh na pozadí v dalším dialogu. Jinak budou oznámení vypnuta.]]> Aplikace pravidelně načítá nové zprávy - denně spotřebuje několik procent baterie. Aplikace nepoužívá push oznámení - data ze zařízení nejsou odesílána na servery. Je vyžadována přístupová fráze Chcete-li dostávat oznámení, zadejte přístupovou frázi do databáze. @@ -386,7 +386,8 @@ Zaslat otázky a nápady Test serveru Servery ICE (jeden na řádek) - Pro připojení budou vyžadováni Onion hostitelé. + Pro připojení budou vyžadováni Onion hostitelé. +\nVezměte prosím na vědomí: nebudete mít možnost připojení k serverům bez adresy .onion. Aktualizovat režim dopravní izolace\? Sestavení aplikace: %s Sdílet odkaz @@ -659,7 +660,6 @@ Na serverech neukládáme žádné vaše kontakty ani zprávy (po doručení). Váš profil, kontakty a doručené zprávy jsou uloženy ve vašem zařízení. Zobrazované jméno - Celé Jméno (volitelné) Vytvořit Jak používat markdown K formátování zpráv můžete použít markdown: @@ -890,8 +890,8 @@ Tmavé Téma Kontakt povolil - zapnuto - vypnout + zaplé + vyplé Chat předvolby Předvolby kontaktu Předvolby skupiny @@ -1093,7 +1093,6 @@ %1$d zprývy přeskočeny. Nahlaste to prosím vývojářům. Může se to stát, když vy nebo vaše připojení použijete starou zálohu databáze. - Žádné mezery! Soubor bude smazán ze serverů. Příjem souboru bude zastaven. Povolte hovory, pouze pokud je váš kontakt povolí. @@ -1302,7 +1301,7 @@ Odesílání potvrzení o doručení je povoleno pro %d kontakty Vypnout potvrzení\? Povolit potvrzení\? - Mohou být přepsány v nastavení kontaktů + Mohou být přepsány v nastavení kontaktů a skupin. šifrování ok povoluji šifrování… šifrování ok pro %s @@ -1353,7 +1352,7 @@ Odesílání doručenky je zakázáno pro %d skupin Vypnout pro všechny skupiny vypnut - Receipts jsou zakázány + Potvrzení jsou zakázána Již brzy! Použít aktuální profil Použít nový incognito profil @@ -1370,24 +1369,41 @@ Malé skupiny (max. 20) %s: %s %s a %s připojen - %s, %s a %s připojeni - %s, %s a %d dalších členů připojeno + %s, %s a %s připojen + %s, %s a %d další členové připojeni Rozepsáno Zobrazit poslední zprávy Tato skupina má více než %1$d členů, doručenky nejsou odeslány. Tato funkce zatím není podporována. Vyzkoušejte další vydání. Databáze bude zašifrována a heslo bude uloženo v klíčence. - Všimněte si prosím: zprávy a relé souborů jsou spojeny prostřednictvím proxy SOCKS. Volání a odesílání náhledů odkazů pomocí přímého připojení.]]> + Všimněte si prosím: relé zpráv a souborů jsou připojeny prostřednictvím proxy SOCKS. Volání a odesílání náhledů odkazů používá přímé připojení.]]> Šifrovat místní soubory - Náhodné heslo je uloženo v nastavení jako prostý text. -\nMůžete jej změnit později. + Náhodná přístupová fráze je uložena v nastavení jako prostý text. +\nMůžete ji později změnit. Heslo pro šifrování databáze bude aktualizováno a uloženo v klíčence. - Odebrat heslo z nastavení\? - Použít náhodné heslo - Uložit heslo v nastavení - Nastavení hesla databáze - Nastavit heslo databáze - Otevřete složku databáze - Heslo bude uloženo v nastavení jako prostý text až jej změníte nebo po restartu aplikace. - Heslo je uloženo v nastavení jako prostý text. + Odebrat přístupovou frázi z nastavení\? + Použít náhodnou přístupovou frázi + Uložit přístupovou frázi v nastavení + Nastavení přístupové fráze databáze + Nastavit přístupovou frázi databáze + Otevřít složku databáze + Přístupová fráze bude uložena v nastavení jako prostý text až ji změníte nebo po restartu aplikace. + Přístupová fráze je uložena v nastavení jako prostý text. + 6 nových jazyků pro rozhraní + Aplikace šifruje nové místní soubory (kromě videí) + Arabština, bulharština, finština, hebrejština, thajština a ukrajinština - díky uživatelům a Weblate. + propojeno napřímo + Otevřít + Šifrování uložených souborů a médií + Chyba vytváření kontaktu + Nová desktopová aplikace! + Odeslat přímou zprávu pro připojení + Objevte a připojte se ke skupinám + Zjednodušený režim inkognito + Vytvořit nový profil v desktopové aplikaci. 💻 + Změnit inkognito při připojování. + - připojit k adresáři skupin (BETA)! +\n- doručenky (až 20 členů). +\n- rychlejší a stabilnější. + odeslat přímou zprávu \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 6fad3dc0ac..63c3d59cad 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -438,7 +438,6 @@ Das Profil wird nur mit Ihren Kontakten geteilt. Der angezeigte Name darf keine Leerzeichen enthalten. Angezeigter Name - Vollständiger Name (optional) Erstellen Über SimpleX @@ -1190,7 +1189,6 @@ Das Senden der Datei wird beendet. Datei beenden Die Datei wird von den Servern gelöscht. - Keine Leerzeichen! Widerrufen Datei widerrufen Datei widerrufen\? @@ -1472,4 +1470,21 @@ Das Passwort wurde in Klartext in den Einstellungen gespeichert. Bitte beachten Sie: Die Nachrichten- und Dateirelais sind per SOCKS Proxy verbunden. Anrufe und gesendete Link-Vorschaubilder nutzen eine direkte Verbindung.]]> Lokale Dateien verschlüsseln + Öffnen + Gespeicherte Dateien & Medien verschlüsseln + Fehler beim Anlegen eines Mitglied-Kontaktes + Neue Desktop-App! + 6 neue Sprachen für die Bedienoberfläche + Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt. + Eine Direktnachricht zum Verbinden senden + Gruppen entdecken und ihnen beitreten + Vereinfachter Inkognito-Modus + Arabisch, Bulgarisch, Finnisch, Hebräisch, Thailändisch und Ukrainisch - Dank der Nutzer und Weblate. + Erstellen eines neuen Profils in der Desktop-App. 💻 + Inkognito beim Verbinden einschalten. + - Verbindung mit dem Directory-Service (BETA)! +\n- Empfangsbestätigungen (für bis zu 20 Mitglieder). +\n- Schneller und stabiler. + Direktnachricht senden + Direkt miteinander verbunden \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 7b2aa4e685..4bcc51663f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -321,7 +321,7 @@ Voltear la cámara Invitación de grupo caducada La invitación al grupo ya no es válida, ha sido eliminada por el remitente. - El grupo se eliminará para tí. ¡No podrá deshacerse! + El grupo será eliminado para tí. ¡No podrá deshacerse! Cómo usar la sintaxis markdown en modo incógnito mediante enlace de un solo uso Dirección de contacto SimpleX @@ -403,7 +403,6 @@ Imagen Archivo no encontrado Guía de uso - Nombre completo (opcional) finalizado AYUDA Exportar base de datos @@ -421,7 +420,7 @@ Ocultar pantalla de aplicaciones en aplicaciones recientes. Cifrar Ampliar la selección de roles - El grupo se eliminará para todos los miembros. ¡No podrá deshacerse! + El grupo será eliminado para todos los miembros. ¡No podrá deshacerse! Activar TCP keep-alive activado para tí error @@ -438,7 +437,7 @@ ayuda Compartir enlace Cómo funciona - El mensaje se eliminará. ¡No podrá deshacerse! + El mensaje será eliminado. ¡No podrá deshacerse! El modo incógnito protege tu privacidad creando un perfil aleatorio por cada contacto. permite que SimpleX se ejecute en segundo plano en el siguiente cuadro de diálogo. De lo contrario las notificaciones se desactivarán.]]> Instalar terminal para SimpleX Chat @@ -513,7 +512,8 @@ ¡Archivo grande! Silenciar Asegúrate de que las direcciones del servidor WebRTC ICE tienen el formato correcto, están separadas por líneas y no duplicadas. - Se requieren hosts .onion para la conexión + Se requieren hosts .onion para la conexión +\nAtención: no podrás conectarte a servidores que no tengan dirección .onion. Se usarán hosts .onion si están disponibles. Inmune a spam y abuso si SimpleX no tiene identificadores de usuario, ¿cómo puede entregar los mensajes\?]]> @@ -1089,13 +1089,12 @@ El ID del siguiente mensaje es incorrecto (menor o igual que el anterior). \nPuede ocurrir por algún bug o cuando la conexión está comprometida. Por favor, informa a los desarrolladores. - %1$d mensajes omitidos. + %1$d mensaje(s) omitido(s). Hash de mensaje incorrecto ID de mensaje incorrecto Puede ocurrir cuando tu o tu contacto estáis usando una copia de seguridad antigua de la base de datos. El hash del mensaje anterior es diferente. - %1$d mensajes no han podido ser descifrados. - ¡Sin espacios! + %1$d mensaje(s) no ha(n) podido ser descifrado(s). Detener archivo El archivo será eliminado de los servidores. Se detendrá la recepción del archivo. @@ -1389,4 +1388,24 @@ Abrir carpeta base de datos La contraseña se almacenará en configuración como texto plano después de cambiarla o reiniciar la aplicación. La contraseña está almacenada en configuración como texto plano. + Abrir + Cifra archivos almacenados y multimedia + Error al establecer contacto con el miembro + Atención: los servidores de retransmisión están conectados mediante SOCKS proxy. Las llamadas y las previsualizaciones de enlaces usan conexión directa.]]> + Cifra archivos locales + Nueva aplicación para PC! + 6 idiomas nuevos para el interfaz + Cifrado de los nuevos archivos locales (excepto vídeos). + Enviar mensaje directo para conectar + Descubre y únete a grupos + Modo incógnito simplificado + Árabe, Búlgaro, Finlandés, Hebreo, Tailandés y Ucraniano - gracias a los usuarios y Weblate. + Crea perfil nuevo en la aplicación para PC. 💻 + Error al enviar invitación + Activa incógnito al conectar. + - conexión al servicio de directorio (BETA)! +\n- confirmaciones de entrega (hasta 20 miembros). +\n- mayor rapidez y estabilidad. + Enviar mensaje directo + conectado directamente \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index b0280d84ae..ce8692130f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -573,7 +573,6 @@ Vanhentunut ryhmäkutsu kutsuttu %1$s Ryhmän koko nimi: - Koko nimi (valinnainen) kursivoitu Avainnipun virhe muokattu @@ -938,7 +937,6 @@ Liity napauttamalla poistettu omistaja - Ei välilyöntejä! hylätty puhelu Näytä Pysäytä tiedosto @@ -1389,4 +1387,9 @@ Tunnuslause on tallennettu asetuksiin selkokielisenä. Huomioi : Viesti- ja tiedostovälittimet yhdistetään SOCKS-proxyn kautta. Puhelut ja linkin esikatselut käyttävät suoraa yhteyttä.]]> Salaa paikalliset tiedostot + Avaa + Uusi työpöytäsovellus! + 6 uutta käyttöliittymän kieltä + Löydä ryhmiä ja liity niihin + Luo uusi profiili työpöytäsovelluksessa. 💻 \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 9ac692cf70..590ba49db8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -363,7 +363,6 @@ Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil. Le profil n\'est partagé qu\'avec vos contacts. Le nom d\'affichage ne peut pas contenir d\'espace. - Nom complet (optionnel) Créer À propos de SimpleX Vous pouvez utiliser le format markdown pour mettre en forme les messages : @@ -751,7 +750,7 @@ Vous essayez d\'inviter un contact avec lequel vous avez partagé un profil incognito à rejoindre le groupe dans lequel vous utilisez votre profil principal ID de base de données Retirer le membre - Envoi de message direct + Envoyer un message direct Ce membre sera retiré du groupe - impossible de revenir en arrière ! Rôle Changer le rôle @@ -1086,7 +1085,6 @@ Erreur de déchiffrement Cela peut se produire lorsque vous ou votre contact avez utilisé une ancienne sauvegarde de base de données. Mauvais hash de message - Pas d\'espace ! Autoriser les appels que si votre contact les autorise. Autorise vos contacts à vous appeler. Appels audio/vidéo @@ -1403,4 +1401,9 @@ - connexion au service d\'annuaire (BETA) ! \n- accusés de réception (jusqu\'à 20 membres). \n- plus rapide et plus stable. + Ouvrir + Erreur lors de la création du contact du membre + Envoyer un message direct pour vous connecter + envoyer un message direct + s\'est connecté.e de manière directe \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 846cd931a2..10af1170b1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -351,7 +351,6 @@ Modifica immagine Esci senza salvare Nome completo: - Nome completo (facoltativo) Come usare il markdown chiamata audio chiamata audio (non crittografata e2e) @@ -420,7 +419,7 @@ cambiato il tuo ruolo in %s cambio indirizzo… cambio indirizzo per %s… - connesso/a + si è connesso/a connesso/a in connessione (accettato) in connessione (annunciato) @@ -771,8 +770,8 @@ membro proprietario ha rimosso - rimosso %1$s - sei stato/a rimosso/a + ha rimosso %1$s + ti ha rimosso/a Tocca per entrare Toccare per entrare in incognito Questo gruppo non esiste più. @@ -820,7 +819,7 @@ Scadenza del protocollo Ricezione via Ripristina i predefiniti - Annulla + Ripristina Salva Salva il profilo del gruppo sec @@ -1105,7 +1104,6 @@ Revocare il file\? Ferma Fermare la ricezione del file\? - Niente spazi! Consenti le chiamate solo se il tuo contatto le consente. Le chiamate audio/video sono vietate. Sia tu che il tuo contatto potete effettuare chiamate. @@ -1403,4 +1401,9 @@ - connessione al servizio directory (BETA)! \n- ricevute di consegna (fino a 20 membri). \n- più veloce e più stabile. + Apri + Errore di creazione del contatto + Invia messaggio diretto per connetterti + invia messaggio diretto + si è connesso/a direttamente \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 8467199bf4..6599a30cc0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -454,7 +454,6 @@ עבור כולם קובץ מתוך גלריה - שם מלא (אופציונלי) קבצים ומדיה קבוצה נמחקה עבור מסוף הצ׳אט @@ -623,7 +622,6 @@ מרקדאון בהודעות רשת ושרתים הגדרות רשת - בלי רווחים! ארכיון מסד נתונים חדש הודעות חבר קבוצה @@ -677,7 +675,8 @@ סמן מאומת לא ייעשה שימוש במארחי Onion כאשר יהיו זמינים. - מארחי Onion יידרשו לחיבור. + יידרשו מארחי onion לחיבור. +\nשימו לב: לא תוכלו להתחבר לשרתים ללא כתובת .onion. לא ייעשה שימוש במארחי Onion. לא ייעשה שימוש במארחי Onion. שיחה שלא נענתה @@ -1377,4 +1376,35 @@ שימוש בסוללה באפליקציה / ללא הגבלה בהגדרות האפליקציה.]]> להתחבר ישירות\? בקשת חיבור תישלח לחבר קבוצה זה. + מסד הנתונים יוצפן וביטוי הסיסמה יאוחסן בהגדרות. + פתח + הצפנת קבצים ומדיה מאוחסנים + שגיאה ביצירת איש קשר + שימו לב: ממסרי הודעות וקבצים מחוברים דרך פרוקסי SOCKS. שיחות ושליחת תצוגות מקדימות של קישורים משתמשים בחיבור ישיר.]]> + הצפין קבצים מקומיים + אפליקציית שולחן עבודה חדשה! + 6 שפות ממשק חדשות + האפליקציה מצפינה קבצים מקומיים חדשים (למעט סרטונים). + ביטוי סיסמה אקראי מאוחסן בהגדרות כטקסט רגיל. +\nאתה יכול לשנות את זה מאוחר יותר. + שלח הודעה ישירה כדי להתחבר + גלה והצטרף לקבוצות + ביטוי הסיסמה להצפנת מסד הנתונים יעודכן ויישמר בהגדרות. + להסיר את ביטוי הסיסמה מההגדרות\? + השתמש בביטוי סיסמה אקראי + שמור את ביטוי הסיסמה בהגדרות + מצב זהות נסתרת מפושט + הגדרת סיסמת מסד נתונים + הגדר את ביטוי הסיסמה של מסד הנתונים + פתח את תיקיית מסד הנתונים + ערבית, בולגרית, פינית, עברית, תאילנדית ואוקראינית - הודות למשתמשים ול-Weblate. + צור פרופיל חדש באפליקציית שולחן העבודה. 💻 + ביטוי הסיסמה יישמר בהגדרות כטקסט רגיל לאחר שתשנה אותו או תפעיל מחדש את האפליקציה. + החלף מצב זהות נסתרת בעת חיבור. + - התחבר לשירות ספריות (ביטא)! +\n- קבלות משלוח (עד 20 חברים). +\n- מהיר ויציב יותר. + ביטוי הסיסמה מאוחסן בהגדרות כטקסט רגיל. + שלח הודעה ישירה + מחובר ישירות \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 8b955ceb8f..94a35dbd8b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -538,7 +538,6 @@ フルネーム: 作成 表示の名前 - フルネーム (任意): 色付き 応答 分散型 @@ -1109,7 +1108,6 @@ ダウングレードしてチャットを開く パスワードを覚えるか、安全に保管してください。失われたパスワードを回復する方法はありません。 ファイルをダウンロード - 空き容量がありません ユーザープロフィールを非表示またはミュートすることができます(メニューを長押し)。 グループのモデレーション こんにちは! @@ -1391,4 +1389,18 @@ パスフレーズは平文として設定に保存されます。 注意: メッセージとファイルのリレーは SOCKS プロキシ経由で接続されます。 通話とリンク プレビューの送信には直接接続が使用されます。]]> ローカルファイルを暗号化する + 開く + 保存されたファイルとメディアを暗号化する + メンバー連絡先の作成中にエラーが発生 + 新しいデスクトップアプリ! + 6つの新しいインターフェース言語 + アプリは新しいローカルファイル(ビデオを除く)を暗号化します。 + ダイレクトメッセージを送信して接続する + グループを見つけて参加する + シークレットモードの簡素化 + アラビア語、ブルガリア語、フィンランド語、ヘブライ語、タイ語、ウクライナ語 - ユーザーとWeblateに感謝します。 + デスクトップアプリで新しいプロファイルを作成します。 💻 + 接続時にシークレットモードを切り替えます。 + ダイレクトメッセージを送る + 直接接続中 \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index bee70ddce6..3c7554c972 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -520,7 +520,6 @@ 그룹 멤버는 보낸 메시지를 영구 삭제할 수 있어요. 그룹 멤버는 음성 메시지를 보낼 수 있어요. 숨긴 프로필 비밀번호 - 이름 (선택 사항) 작동 방식 숨기기 : 보냄 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml index ac903ff708..92bd3e381a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml @@ -54,7 +54,6 @@ സ്വീകരിക്കുക സ്വീകരിക്കുക നിരസിക്കുക - മുഴുവൻ പേര് (ഇഷ്ടാനുസൃതമായ) ഉപയോക്താക്കൾക്ക് നന്ദി - Weblate വഴി സംഭാവന ചെയ്യുക! ഉപയോക്താക്കൾക്ക് നന്ദി - Weblate വഴി സംഭാവന ചെയ്യുക! ഉപയോക്താക്കൾക്ക് നന്ദി - Weblate വഴി സംഭാവന ചെയ്യുക! @@ -113,7 +112,6 @@ സെർവർ ചേർക്കുക… മറ്റൊരു ഉപകരണത്തിലേക്ക് ചേർക്കുക സ്വയമേവ സ്വീകരിക്കുക - സ്ഥലമില്ല! സ്ഥിരീകരണത്തിനായി കാത്തിരിക്കുന്നു… അവഗണിക്കുക തുറക്കുക diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index aaa79b9a71..7cbb9bf757 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -315,7 +315,6 @@ Email Bewerk afbeelding Afsluiten zonder opslaan - Volledige naam (optioneel) e2e versleuteld video gesprek Schakel oproepen vanaf het vergrendelscherm in via Instellingen. Ophangen @@ -479,7 +478,7 @@ lid worden als %s Het kan gebeuren wanneer: \n1. De berichten zijn na 2 dagen verlopen bij de verzendende client of na 30 dagen op de server. -\n2. Decodering van het bericht is mislukt, omdat u of uw contact een oude databaseback-up heeft gebruikt. +\n2. Decodering van het bericht is mislukt, omdat u of uw contact een oude database back-up heeft gebruikt. \n3. De verbinding is verbroken. Deel nemen aan groep Verlaten @@ -1086,14 +1085,13 @@ Decodering fout De hash van het vorige bericht is anders. %1$d berichten overgeslagen. - Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt. + Het kan gebeuren wanneer u of de ander een oude database back-up gebruikt. Meld het alsjeblieft aan de ontwikkelaars. Onjuiste bericht hash Onjuiste bericht-ID De ID van het volgende bericht is onjuist (minder of gelijk aan het vorige). \nHet kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. %1$d berichten konden niet worden ontsleuteld. - Geen spaties! Het ontvangen van het bestand wordt gestopt. Intrekken Bestand intrekken @@ -1401,4 +1399,9 @@ - maak verbinding met de directoryservice (BETA)! \n- ontvangst bevestiging (tot 20 leden). \n- sneller en stabieler. + Open + Fout bij aanmaken contact + Stuur een direct bericht om verbinding te maken + stuur een direct bericht + direct verbonden \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 35630919e6..4e14345e94 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -411,7 +411,6 @@ Utwórz Zdecentralizowane zakończona - Pełna nazwa (opcjonalna) Jak korzystać z markdown Odporność na spam i nadużycia kursywa @@ -1099,7 +1098,6 @@ Plik zostanie usunięty z serwerów. Tylko Ty możesz wykonywać połączenia. Odbieranie pliku zostanie przerwane. - Bez spacji! Zezwalaj na połączenia tylko wtedy, gdy Twój kontakt na to pozwala. Zezwól swoim kontaktom na połączenia do Ciebie. Połączenia audio/wideo @@ -1403,4 +1401,9 @@ - połącz się z usługą katalogową (BETA)! \n- potwierdzenia dostaw (do 20 członków). \n- szybszy i stabilniejszy. + Otwórz + Błąd tworzenia kontaktu członka + Wyślij wiadomość bezpośrednią aby połączyć + wyślij wiadomość bezpośrednią + połącz bezpośrednio \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 07836dc98a..0cb5b872b0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -405,7 +405,6 @@ Se você optar por rejeitar o remetente NÃO será notificado. Código de segurança incorreto! Instale o SimpleX para terminal - Nome Completo (opcional) Como funciona Imune a spam e abuso Vire a câmera @@ -1097,7 +1096,6 @@ Parar O arquivo será excluído dos servidores. Revogar arquivo\? - Sem espaços! %1$d mensagens ignoradas Chamadas de áudio/vídeo Chamadas de áudio/vídeo são proibidas. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index 3de938828f..f083705545 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -635,7 +635,6 @@ encriptado ponta a ponta desligado Ler código QR.]]> - Sem espaços! contato não tem encriptação ponta a ponta oferecido %s: %2s desligado diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index d2b4465ec4..f28ef14d14 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -436,7 +436,6 @@ Профиль отправляется только Вашим контактам. Имя профиля не может содержать пробелы. Имя профиля - Полное имя (не обязательно) Создать О SimpleX @@ -1190,7 +1189,6 @@ Только Вы можете совершать звонки. Только Ваш контакт может совершать звонки. Запретить аудио/видео звонки. - Без пробелов! Быстрые и не нужно ждать, когда отправитель онлайн! Польский интерфейс Установите код вместо системной аутентификации. @@ -1485,4 +1483,10 @@ \n- отчеты о доставке (до 20 членов). \n- быстрее и стабильнее. Пароль хранится в настройках, как открытый текст. + Открыть + Ошибка при создании контакта + Послать прямое сообщение контакту + Ошибка отправки приглашения + Послать прямое сообщение + соединен напрямую \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index ad9cff0cc5..b31b2a3a24 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -378,7 +378,6 @@ รหัสผ่านโปรไฟล์ที่ซ่อนอยู่ ซ่อนโปรไฟล์ เกิดข้อผิดพลาดในการบันทึกรหัสผ่านผู้ใช้ - ชื่อเต็ม (ไม่บังคับ) ชื่อที่แสดง ชื่อที่แสดงต้องไม่มีช่องว่าง วิธีใช้มาร์กดาวน์ @@ -675,7 +674,6 @@ โฮสต์หัวหอมจะไม่ถูกใช้ โฮสต์หัวหอมจะไม่ถูกใช้ รหัสผ่านที่จะแสดง - ไม่มีช่องว่าง! สายที่ไม่ได้รับ ผู้คนสามารถเชื่อมต่อกับคุณผ่านลิงก์ที่คุณแบ่งปันเท่านั้น โปรโตคอลและโค้ดโอเพ่นซอร์ส – ใคร ๆ ก็สามารถเปิดใช้เซิร์ฟเวอร์ได้ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index ab4a04a373..1eb396c787 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -593,7 +593,6 @@ Kaydetmeden çık Profili gizle Kullanıcı parolası kaydedilirken hata oluştu - Ad ve Soyad (isteğe bağlı) Gizli profil parolası Markdown nasıl kullanılır Nasıl çalışıyor @@ -934,7 +933,6 @@ Çoklu sohbet profilleri Daha fazla gelişme yakında geliyor! ay - Boşluk yok! Yeni veri tabanı arşivi Eski veri tabanı arşivi Alınan veya gönderilen dosya yok diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 1c3ec176d4..fbf62d4f35 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -660,7 +660,6 @@ Прихований пароль профілю Профіль доступний лише вашим контактам. Ім\'я не може містити пробілів. - Повне ім\'я (необов\'язково) Як використовувати націнку Ви можете використовувати розмітку для форматування повідомлень: Створіть свій профіль @@ -1207,7 +1206,6 @@ SimpleX Адреса Показати QR-код Приєднання до групи - Ніяких пробілів! курсив Сервери WebRTC ICE Увімкніть дзвінки з екрана блокування через Налаштування. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index bf0fe570cc..1d266beaf4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -67,7 +67,7 @@ 通过群组链接连接? 通过群组链接/二维码连接 总是通过中继连接 - 允许您的联系人永久删除已发送消息。 + 允许您的联系人不可撤回地删除已发送消息。 联系人允许 仅有您的联系人许可后才允许语音消息。 您: %1$s @@ -359,7 +359,6 @@ 显示名: 全名: 显示名 - 全名(可选) 已结束 群组已删除 将为所有成员删除群组——此操作无法撤消! @@ -1095,7 +1094,6 @@ 上一条消息的散列不同。 下一条消息的 ID 不正确(小于或等于上一条)。 \n它可能是由于某些错误或连接被破坏才发生。 - 没有空格! 停止文件 停止发送文件? 即将停止发送文件。 @@ -1403,4 +1401,9 @@ - 连接到目录服务(BETA)! \n- 发送回执(至多20名成员)。 \n- 更快,更稳定。 + 打开 + 创建成员联系人时出错 + 发送私信来连接 + 发送私信 + 已直连 \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 24b02e64e3..b1d988e465 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -72,7 +72,6 @@ 顯示的名稱中不能有空白。 儲存設定? 顯示名稱 - 全名(可選擇的) 通話出錯 正在撥打… 通話中 @@ -1085,7 +1084,6 @@ 錯誤的訊息 ID 當你或你的連結在用舊的數據庫備份時會發生。 上一則訊息的雜奏則是不同的。 - 沒有空位! 應用程式密碼 迅速以及不用等待發送者在線! 波蘭文界面 From 227007c8f611b62c7b5400d43f8cf54c52ce7469 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 10 Nov 2023 19:49:23 +0200 Subject: [PATCH 170/294] add /switch remote host (#3342) * Add SwitchRemoteHost * Add message test * Match remote prefix and the rest of the line * Move prefix match to utils --- src/Simplex/Chat.hs | 2 ++ src/Simplex/Chat/Controller.hs | 6 ++-- src/Simplex/Chat/Remote.hs | 11 +++++++ src/Simplex/Chat/View.hs | 6 ++++ tests/ChatTests/Utils.hs | 3 ++ tests/RemoteTests.hs | 52 ++++++++++++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 664661603b..dad00fcefa 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1953,6 +1953,7 @@ processChatCommand = \case p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} SetLocalDeviceName name -> withUser_ $ chatWriteVar localDeviceName name >> ok_ ListRemoteHosts -> withUser_ $ CRRemoteHostList <$> listRemoteHosts + SwitchRemoteHost rh_ -> withUser_ $ CRCurrentRemoteHost <$> switchRemoteHost rh_ StartRemoteHost rh_ -> withUser_ $ do (remoteHost_, inv) <- startRemoteHost' rh_ pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv} @@ -5977,6 +5978,7 @@ chatCommandP = "/set device name " *> (SetLocalDeviceName <$> textP), -- "/create remote host" $> CreateRemoteHost, "/list remote hosts" $> ListRemoteHosts, + "/switch remote host " *> (SwitchRemoteHost <$> ("local" $> Nothing <|> (Just <$> A.decimal))), "/start remote host " *> (StartRemoteHost <$> ("new" $> Nothing <|> (Just <$> ((,) <$> A.decimal <*> (" multicast=" *> onOffP <|> pure False))))), "/stop remote host " *> (StopRemoteHost <$> ("new" $> RHNew <|> RHId <$> A.decimal)), "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6832bb5621..a9950372bc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -425,7 +425,7 @@ data ChatCommand -- | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost (Maybe (RemoteHostId, Bool)) -- ^ Start new or known remote host with optional multicast for known host - -- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host + | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host | StopRemoteHost RHKey -- ^ Shut down a running session | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} @@ -456,7 +456,7 @@ allowRemoteCommand = \case QuitChat -> False ListRemoteHosts -> False StartRemoteHost _ -> False - -- SwitchRemoteHost {} -> False + SwitchRemoteHost {} -> False StoreRemoteFile {} -> False GetRemoteFile {} -> False StopRemoteHost _ -> False @@ -644,6 +644,7 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} + | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text} | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} @@ -1051,6 +1052,7 @@ throwDBError = throwError . ChatErrorDatabase -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteHostError = RHEMissing -- ^ No remote session matches this identifier + | RHEInactive -- ^ A session exists, but not active | RHEBusy -- ^ A session is already running | RHEBadState -- ^ Illegal state transition | RHEBadVersion {appVersion :: AppVersion} diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 53b63d72b1..ef5589e5aa 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -248,6 +248,17 @@ listRemoteHosts = do rhInfo active rh@RemoteHost {remoteHostId} = remoteHostInfo rh (M.member (RHId remoteHostId) active) +switchRemoteHost :: ChatMonad m => Maybe RemoteHostId -> m (Maybe RemoteHostInfo) +switchRemoteHost rhId_ = do + rhi_ <- forM rhId_ $ \rhId -> do + let rhKey = RHId rhId + rhi <- withError (const $ ChatErrorRemoteHost rhKey RHEMissing) $ (`remoteHostInfo` True) <$> withStore (`getRemoteHost` rhId) + active <- chatReadVar remoteHostSessions + case M.lookup rhKey active of + Just RHSessionConnected {} -> pure rhi + _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive + rhi_ <$ chatWriteVar currentRemoteHost rhId_ + -- XXX: replacing hostPairing replaced with sessionActive, could be a ($>) remoteHostInfo :: RemoteHost -> Bool -> RemoteHostInfo remoteHostInfo RemoteHost {remoteHostId, storePath, hostName} sessionActive = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ca47cb15e5..177f3400dd 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -276,6 +276,12 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] CRRemoteHostCreated RemoteHostInfo {remoteHostId} -> ["remote host " <> sShow remoteHostId <> " created"] + CRCurrentRemoteHost rhi_ -> + [ maybe + "Using local profile" + (\RemoteHostInfo {remoteHostId = rhId, hostName} -> "Using remote host " <> sShow rhId <> " (" <> plain hostName <> ")") + rhi_ + ] CRRemoteHostList hs -> viewRemoteHosts hs CRRemoteHostStarted {remoteHost_, invitation} -> [ maybe "new remote host started" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " started") remoteHost_, diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index a2aff4bf54..83b0d507b9 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -327,6 +327,9 @@ cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line ($<#) :: HasCallStack => (TestCC, String) -> String -> Expectation (cc, uName) $<# line = (dropTime . dropUser uName <$> getTermLine cc) `shouldReturn` line +(^<#) :: HasCallStack => (TestCC, String) -> String -> Expectation +(cc, p) ^<# line = (dropTime . dropStrPrefix p <$> getTermLine cc) `shouldReturn` line + (⩗) :: HasCallStack => TestCC -> String -> Expectation cc ⩗ line = (dropTime . dropReceipt <$> getTermLine cc) `shouldReturn` line diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index b35e54032f..9c135a81a5 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -41,6 +41,8 @@ remoteTests = describe "Remote" $ do describe "remote files" $ do it "store/get/send/receive files" remoteStoreFileTest it "should send files from CLI wihtout /store" remoteCLIFileTest + it "switches remote hosts" switchRemoteHostTest + it "indicates remote hosts" indicateRemoteHostTest -- * Chat commands @@ -323,6 +325,56 @@ remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile where cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} +switchRemoteHostTest :: FilePath -> IO () +switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do + startRemote mobile desktop + contactBob desktop bob + + desktop ##> "/contacts" + desktop <## "bob (Bob)" + + desktop ##> "/switch remote host local" + desktop <## "Using local profile" + desktop ##> "/contacts" + + desktop ##> "/switch remote host 1" + desktop <## "Using remote host 1 (Mobile)" + desktop ##> "/contacts" + desktop <## "bob (Bob)" + + desktop ##> "/switch remote host 123" + desktop <## "remote host 123 error: RHEMissing" + + stopDesktop mobile desktop + desktop ##> "/contacts" + desktop ##> "/switch remote host 1" + desktop <## "remote host 1 error: RHEInactive" + desktop ##> "/contacts" + +indicateRemoteHostTest :: FilePath -> IO () +indicateRemoteHostTest = testChat4 aliceProfile aliceDesktopProfile bobProfile cathProfile $ \mobile desktop bob cath -> do + connectUsers desktop cath + startRemote mobile desktop + contactBob desktop bob + -- remote contact -> remote host + bob #> "@alice hi" + desktop <#. "bob> hi" + -- local -> remote + cath #> "@alice_desktop hello" + (desktop, "[local] ") ^<# "cath> hello" + -- local -> local + desktop ##> "/switch remote host local" + desktop <## "Using local profile" + desktop <##> cath + -- local -> remote + bob #> "@alice what's up?" + (desktop, "[remote: 1] ") ^<# "bob> what's up?" + + -- local -> local after disconnect + stopDesktop mobile desktop + desktop <##> cath + cath <##> desktop + -- * Utils startRemote :: TestCC -> TestCC -> IO () From 9cc232054c9cf620b52af05403783eccb8529743 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 10 Nov 2023 17:57:02 +0000 Subject: [PATCH 171/294] website: translations (#3345) * core: notify contact about contact deletion * Translated using Weblate (French) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/nl/ * Translated using Weblate (Italian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Arabic) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * Translated using Weblate (Hebrew) Currently translated at 34.1% (86 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Spanish) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/ * Translated using Weblate (Hebrew) Currently translated at 41.2% (104 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Hebrew) Currently translated at 74.6% (188 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Czech) Currently translated at 92.0% (232 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/cs/ * Translated using Weblate (Arabic) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ * Translated using Weblate (Hebrew) Currently translated at 90.4% (228 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Hebrew) Currently translated at 94.8% (239 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/ * Translated using Weblate (German) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: M1K4 Co-authored-by: Random Co-authored-by: jonnysemon Co-authored-by: ItaiShek Co-authored-by: No name Co-authored-by: Eric Co-authored-by: zenobit Co-authored-by: mlanp --- website/langs/ar.json | 11 ++- website/langs/cs.json | 2 +- website/langs/de.json | 9 +- website/langs/es.json | 9 +- website/langs/fr.json | 9 +- website/langs/he.json | 180 ++++++++++++++++++++++++++++++++++++- website/langs/it.json | 9 +- website/langs/nl.json | 9 +- website/langs/zh_Hans.json | 17 ++-- 9 files changed, 240 insertions(+), 15 deletions(-) diff --git a/website/langs/ar.json b/website/langs/ar.json index 3fe698a3fe..09a8a268ac 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -132,7 +132,7 @@ "donate-here-to-help-us": "تبرّع هنا لمساعدتنا", "sign-up-to-receive-our-updates": "اشترك للحصول على آخر مستجداتنا", "enter-your-email-address": "أدخل عنوان بريدك الإلكتروني", - "get-simplex": "احصل على SimpleX desktop app", + "get-simplex": "احصل على تطبيق سطح المكتب SimpleX", "why-simplex-is": "لماذا SimpleX", "unique": "فريد من نوعه", "learn-more": "اقرأ أكثر", @@ -243,5 +243,12 @@ "releases-to-this-repo-are-done-1-2-days-later": "يتم إصدار الإصدارات إلى هذا المستودع بعد يوم أو يومين", "f-droid-page-simplex-chat-repo-section-text": "لإضافته إلى عميل F-Droid، امسح رمز QR أو استخدم عنوان URL هذا:", "f-droid-page-f-droid-org-repo-section-text": "مستودعات SimpleX Chat و F-Droid.org مبنية على مفاتيح مختلفة. للتبديل، يرجى تصدير قاعدة بيانات الدردشة وإعادة تثبيت التطبيق.", - "comparison-section-list-point-4a": "مُرحلات SimpleX لا يمكنها أن تتنازل عن تشفير بين الطرفين. تحقق من رمز الأمان للتخفيف من الهجوم على القناة خارج النطاق" + "comparison-section-list-point-4a": "مُرحلات SimpleX لا يمكنها أن تتنازل عن تشفير بين الطرفين. تحقق من رمز الأمان للتخفيف من الهجوم على القناة خارج النطاق", + "hero-overlay-3-title": "التقييم الأمني", + "hero-overlay-card-3-p-2": "قامت Trail of Bits بمراجعة مكونات التشفير والشبكات الخاصة بمنصة SimpleX في نوفمبر 2022.", + "hero-overlay-card-3-p-3": "اقرأ المزيد في الإعلان.", + "jobs": "انضم للفريق", + "hero-overlay-3-textlink": "التقييم الأمني", + "hero-overlay-card-3-p-1": "Trail of Bits هي شركة رائدة في مجال الاستشارات الأمنية والتكنولوجية، ومن بين عملائها شركات التكنولوجيا الكبرى والوكالات الحكومية ومشاريع blockchain الكبرى.", + "docs-dropdown-9": "التنزيلات" } diff --git a/website/langs/cs.json b/website/langs/cs.json index 93aed0c9b2..d087fd5bec 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -2,7 +2,7 @@ "simplex-private-card-10-point-2": "Umožňuje doručovat zprávy bez identifikátoru uživatelských profilů, což poskytuje lepší soukromí metadat než alternativy.", "simplex-unique-4-overlay-1-title": "Plně decentralizované — uživatelé vlastní síť SimpleX", "hero-overlay-card-1-p-6": "Přečtěte si více v SimpleX whitepaper.", - "hero-overlay-card-1-p-2": "Pro doručování zpráv používá SimpleX namísto ID uživatelů používaných všemi ostatními platformami dočasné anonymní párové identifikátory front zpráv, oddělené pro každé z vašich připojení - neexistují žádné dlouhodobé identifikátory.", + "hero-overlay-card-1-p-2": "Pro doručování zpráv používá SimpleX namísto ID uživatelů používaných všemi ostatními platformami dočasné anonymní párové identifikátory front zpráv, oddělené pro každé z vašich připojení — neexistují žádné dlouhodobé identifikátory.", "hero-overlay-card-1-p-3": "Definujete, které servery se mají používat k přijímání zpráv, vašich kontaktů — servery, které používáte k odesílání zpráv. Každá konverzace bude pravděpodobně používat dva různé servery.", "hero-overlay-card-2-p-3": "I v těch nejsoukromějších aplikacích, které používají služby Tor v3, pokud mluvíte se dvěma různými kontakty prostřednictvím stejného profilu, může být prokázáno, že jsou spojeni se stejnou osobou.", "simplex-network-overlay-card-1-p-1": "P2P protokoly a aplikace pro zasílání zpráv mají různé problémy, které je činí méně spolehlivými než SimpleX, složitějšími na analýzu a zranitelnými vůči několika typům útoků.", diff --git a/website/langs/de.json b/website/langs/de.json index 052b73235a..5eb432720e 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -243,5 +243,12 @@ "simplex-chat-repo": "SimpleX Chat Repository", "stable-and-beta-versions-built-by-developers": "Von den Entwicklern erstellte stabile und Beta-Versionen", "f-droid-page-simplex-chat-repo-section-text": "Um es Ihrem F-Droid-Client hinzuzufügen scannen Sie den QR-Code oder nutzen Sie diese URL:", - "comparison-section-list-point-4a": "SimpleX-Relais können die E2E-Verschlüsselung nicht kompromittieren. Überprüfen Sie den Sicherheitscode, um einen möglichen Angriff auf den Out-of-Band-Kanal zu entschärfen" + "comparison-section-list-point-4a": "SimpleX-Relais können die E2E-Verschlüsselung nicht kompromittieren. Überprüfen Sie den Sicherheitscode, um einen möglichen Angriff auf den Out-of-Band-Kanal zu entschärfen", + "hero-overlay-3-title": "Sicherheits-Gutachten", + "hero-overlay-card-3-p-2": "Trail of Bits untersuchte im November 2022 die kryptografischen und Netzwerk-Komponenten der SimpleX-Plattform.", + "hero-overlay-card-3-p-3": "Lesen Sie mehr dazu in der Ankündigung.", + "jobs": "Treten Sie dem Team bei", + "hero-overlay-3-textlink": "Sicherheits-Gutachten", + "hero-overlay-card-3-p-1": "Trail of Bits ist eine führende Security- und Technologie-Unternehmensberatung, deren Kunden aus den Bereichen Big-Tech, Regierungsbehörden und großen Blockchain-Projekten stammen.", + "docs-dropdown-9": "Downloads" } diff --git a/website/langs/es.json b/website/langs/es.json index dfbb95d725..7c418b7e25 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -243,5 +243,12 @@ "f-droid-page-f-droid-org-repo-section-text": "Los repositorios de SimpleX Chat y F-Droid.org firman con distinto certificado. Para cambiar, por favor exportar la base de datos y reinstala la aplicación.", "signing-key-fingerprint": "Huella digital de la clave de firma (SHA-256)", "releases-to-this-repo-are-done-1-2-days-later": "Las versiones aparecen 1-2 días más tarde en este repositorio", - "comparison-section-list-point-4a": "Los servidores de retransmisión no pueden comprometer la encriptación e2e. Para evitar posibles ataques, verifique el código de seguridad mediante un canal alternativo" + "comparison-section-list-point-4a": "Los servidores de retransmisión no pueden comprometer la encriptación e2e. Para evitar posibles ataques, verifique el código de seguridad mediante un canal alternativo", + "hero-overlay-3-title": "Evaluación de la seguridad", + "hero-overlay-card-3-p-2": "Trail of Bits revisó la criptografía y los componentes de red de la plataforma SimpleX en noviembre de 2022.", + "hero-overlay-card-3-p-3": "Más información en la noticia.", + "jobs": "Únete al equipo", + "hero-overlay-3-textlink": "Evaluación de la seguridad", + "hero-overlay-card-3-p-1": "Trail of Bits es una consultora de seguridad y tecnología líder cuyos clientes incluyen grandes tecnológicas, agencias gubernamentales e importantes proyectos de blockchain.", + "docs-dropdown-9": "Descargas" } diff --git a/website/langs/fr.json b/website/langs/fr.json index 37dc6d5a14..b4d0ea17af 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -244,5 +244,12 @@ "signing-key-fingerprint": "Empreinte de signature numérique (SHA-256)", "f-droid-org-repo": "Dépot F-Droid.org", "stable-versions-built-by-f-droid-org": "Versions stables créées par F-Droid.org", - "comparison-section-list-point-4a": "Les relais SimpleX ne peuvent pas compromettre le chiffrement e2e. Vérifier le code de sécurité pour limiter les attaques sur le canal hors bande" + "comparison-section-list-point-4a": "Les relais SimpleX ne peuvent pas compromettre le chiffrement e2e. Vérifier le code de sécurité pour limiter les attaques sur le canal hors bande", + "hero-overlay-3-title": "Évaluation de sécurité", + "hero-overlay-card-3-p-2": "Trail of Bits a examiné les composants cryptographiques et réseau de la plateforme SimpleX en novembre 2022.", + "hero-overlay-card-3-p-3": "En savoir plus sur l'annonce.", + "jobs": "Rejoignez notre équipe", + "hero-overlay-3-textlink": "Évaluation de sécurité", + "hero-overlay-card-3-p-1": "Trail of Bits est un cabinet leader dans le secteur de la sécurité et des technologies qui compte parmi ses clients des grandes entreprises de la tech, des agences gouvernementales et d'importants projets de blockchain.", + "docs-dropdown-9": "Téléchargements" } diff --git a/website/langs/he.json b/website/langs/he.json index c814e405c0..f4e776c67f 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -33,7 +33,7 @@ "simplex-private-card-2-point-1": "שכבה נוספת של הצפנת שרת למסירה לנמען, כדי למנוע קורלציה בין תעבורת השרת המתקבלת ונשלחת במקרה שאבטחת TLS נפגעה.", "simplex-private-card-3-point-1": "עבור חיבורי שרת-לקוח, נעשה שימוש רק ב-TLS 1.2/1.3 עם אלגוריתמים חזקים.", "simplex-private-card-3-point-2": "טביעת אצבע של שרת ואיגוד ערוצים מונעים התקפת אדם בתווך (MITM) והתקפת שליחה מחדש (Replay attack).", - "simplex-private-card-5-point-1": "SimpleX משתמש בריפוד תוכן עבור כל שכבת הצפנה כדי לסכל התקפות בגודל הודעה.", + "simplex-private-card-5-point-1": "SimpleX משתמש בריווח התוכן עבור כל שכבת הצפנה כדי לסכל התקפות על גודל הודעה.", "simplex-private-card-5-point-2": "זה גורם להודעות בגדלים שונים להיראות זהים לשרתים ולמשקיפים ברשת.", "simplex-private-card-6-point-1": "פלטפורמות תקשורת רבות חשופות להתקפות אדם בתווך (MITM) על ידי שרתים או ספקי רשת.", "simplex-private-card-9-point-2": "זה מפחית את וקטורי ההתקפה, בהשוואה למתווכי הודעות מסורתיים, ואת המטא-נתונים הזמינים.", @@ -74,5 +74,181 @@ "privacy-matters-1-overlay-1-title": "פרטיות חוסכת לכם כסף", "privacy-matters-2-overlay-1-linkText": "פרטיות מעניקה לכם עוצמה", "privacy-matters-3-overlay-1-linkText": "פרטיות מגנה על החופש שלכם", - "simplex-explained-tab-1-p-1": "אתם יכולים ליצור אנשי קשר וקבוצות, ולנהל שיחות דו-כיווניות, כמו בכל תוכנה אחרת לשליחת הודעות." + "simplex-explained-tab-1-p-1": "אתם יכולים ליצור אנשי קשר וקבוצות, ולנהל שיחות דו-כיווניות, כמו בכל תוכנה אחרת לשליחת הודעות.", + "hero-overlay-3-title": "הערכת אבטחה", + "simplex-unique-1-overlay-1-title": "פרטיות מלאה של הזהות, הפרופיל, אנשי הקשר והמטא נתונים שלך", + "simplex-unique-3-overlay-1-title": "בעלות, שליטה ואבטחה של הנתונים שלך", + "simplex-unique-2-overlay-1-title": "ההגנה הטובה ביותר מפני ספאם וניצול לרעה", + "simplex-unique-3-title": "אתה שולט בנתונים שלך", + "hero-overlay-3-textlink": "הערכת אבטחה", + "simplex-unique-4-overlay-1-title": "מבוזר לחלוטין — המשתמשים הם הבעלים של רשת SimpleX", + "simplex-unique-2-title": "אתה מוגן
מפני ספאם וניצול לרעה", + "simplex-unique-4-title": "רשת SimpleX בבעלותך", + "simplex-unique-1-title": "יש לך פרטיות מלאה", + "simplex-private-card-10-point-1": "SimpleX משתמש בכתובות ואישורים אנונימיים זמניים בזוגות עבור כל איש קשר של משתמש או חבר בקבוצה.", + "privacy-matters-overlay-card-1-p-1": "הרבה חברות גדולות משתמשות במידע עם מי אתה בקשר כדי להעריך את ההכנסה שלך, למכור לך מוצרים שאתה לא באמת צריך ולקבוע את המחירים.", + "hero-overlay-card-3-p-2": "Trail of Bits סקרה את רכיבי ההצפנה והרשת של פלטפורמת SimpleX בנובמבר 2022.", + "hero-overlay-card-2-p-4": "SimpleX מגן מפני התקפות אלה בכך שאין בעיצובו מזהי משתמש. ואם אתה משתמש במצב זהות נסתרת, יהיה לך שם תצוגה שונה לכל איש קשר, תוך הימנעות מכל מידע משותף ביניהם.", + "hero-overlay-card-2-p-1": "כאשר למשתמשים יש זהויות מתמשכות, גם אם זה רק מספר אקראי, כמו מזהה הפעלה, קיים סיכון שהספק או התוקף יוכלו לראות כיצד המשתמשים מחוברים וכמה הודעות הם שולחים.", + "hero-overlay-card-1-p-5": "רק מכשירי לקוח מאחסנים פרופילי משתמשים, אנשי קשר וקבוצות; ההודעות נשלחות עם הצפנה דו-שכבתית מקצה לקצה.", + "privacy-matters-overlay-card-1-p-2": "קמעונאים מקוונים יודעים שאנשים עם הכנסה נמוכה יותר נוטים יותר לבצע רכישות דחופות, ולכן הם עשויים לגבות מחירים גבוהים יותר או להסיר הנחות.", + "hero-overlay-card-3-p-3": "קרא עוד בהודעה.", + "hero-overlay-card-1-p-2": "כדי להעביר הודעות, במקום מזהי משתמש המשמשים את כל הפלטפורמות האחרות, SimpleX משתמש במזהים אנונימיים זמניים זוגיים של תורי הודעות, נפרדים עבור כל אחד מהחיבורים שלך — אין מזהים לטווח ארוך.", + "hero-overlay-card-2-p-2": "לאחר מכן הם יוכלו לקשר מידע זה עם הרשתות החברתיות הציבוריות הקיימות, ולקבוע כמה זהויות אמיתיות.", + "privacy-matters-overlay-card-1-p-3": "חלק מחברות פיננסיות וביטוח משתמשות בגרפים חברתיים כדי לקבוע שיעורי ריבית ופרמיות. לעתים קרובות זה גורם לאנשים עם הכנסה נמוכה יותר לשלם יותר — זה ידוע בתור 'פרמיית עוני'.", + "hero-overlay-card-1-p-4": "עיצוב זה מונע דליפה כלשהי של משתמשים' מטא נתונים ברמת האפליקציה. כדי לשפר עוד יותר את הפרטיות ולהגן על כתובת ה-IP שלך, תוכל להתחבר לשרתי הודעות באמצעות Tor.", + "hero-overlay-card-2-p-3": "אפילו עם האפליקציות הפרטיות ביותר המשתמשות בשירותי Tor v3, אם אתה מדבר עם שני אנשי קשר שונים דרך אותו פרופיל הם יכולים להוכיח שהם מחוברים לאותו אדם.", + "hero-overlay-card-3-p-1": "Trail of Bits היא חברת ייעוץ מובילה בתחום אבטחה וטכנולוגיה שלקוחותיה כוללים ביג טק, סוכנויות ממשלתיות ופרויקטי בלוקצ'יין גדולים.", + "hero-overlay-card-1-p-1": "משתמשים רבים שאלו: אם ל-SimpleX אין מזהי משתמש, איך הוא יכול לדעת לאן להעביר הודעות?", + "simplex-network-overlay-card-1-li-2": "לעיצוב של SimpleX, בניגוד לרוב רשתות ה-P2P, אין מזהי משתמש גלובלי מכל סוג שהוא, אפילו זמני, והוא משתמש רק במזהים זמניים זוגיים, המספקים אנונימיות טובה יותר והגנה על מטא נתונים.", + "hero-overlay-card-1-p-6": "קרא עוד בסקירה הטכנית של SimpleX.", + "simplex-network-overlay-card-1-p-1": "לפרוטוקולי הודעות ואפליקציות P2P יש בעיות שונות שהופכות אותן לפחות אמינות מ-SimpleX, מורכבות יותר לניתוח, ופגיעות למספר סוגי התקפות.", + "hero-overlay-card-1-p-3": "אתה מגדיר באילו שרתים להשתמש כדי לקבל את ההודעות, אנשי הקשר שלך — השרתים שבהם אתה משתמש כדי לשלוח את ההודעות אליהם. סביר שכל שיחה תהשתמש בשני שרתים שונים.", + "copy-the-command-below-text": "העתיקו את הפקודה למטה והשתמש בה בצ'אט:", + "contact-hero-p-1": "המפתחות הציבוריים וכתובת תור ההודעות בקישור זה אינם נשלחים דרך הרשת כאשר אתם צופים בדף זה — הם כלולים בקטע הגיבוב (hash) של כתובת הקישור.", + "scan-the-qr-code-with-the-simplex-chat-app": "סרקו את קוד ה-QR באפליקציית SimpleX Chat", + "simplex-unique-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של SimpleX עד שמתקבלות, ואז הן נמחקות לצמיתות.", + "simplex-unique-overlay-card-3-p-3": "שלא כמו שרתי רשת מאוחדים (דוא\"ל, XMPP או Matrix), שרתי SimpleX אינם מאחסנים חשבונות משתמשים, הם רק מעבירים הודעות, ומגנים על הפרטיות של שני הצדדים.", + "privacy-matters-overlay-card-3-p-2": "אחד הסיפורים המזעזעים ביותר הוא החוויה של מוחמד אולד סלאחי המתוארת בספר הזיכרונות שלו ומוצגת בסרט \"המאוריטני\". הוא הוכנס למתקן המעצר בגואנטנמו, ללא משפט, ועונה שם במשך 15 שנים לאחר שיחת טלפון לקרוב משפחתו באפגניסטן, בחשד שהיה מעורב בפיגועים ב-11 בספטמבר, למרות שחי בגרמניה בעשר השנים הקודמות.", + "simplex-network-2-desc": "שרתי ממסר SimpleX אינם מאחסנים פרופילי משתמש, אנשי קשר והודעות שנמסרו, אינם מתחברים זה לזה, ואין ספריית שרתים.", + "installing-simplex-chat-to-terminal": "התקנת SimpleX Chat למסוף שורת הפקודה", + "use-this-command": "השתמשו בפקודה הזו:", + "to-make-a-connection": "כדי ליצור חיבור:", + "enter-your-email-address": "הזינו את כתובת הדוא\"ל שלכם", + "tap-to-close": "הקישו כדי לסגור", + "simplex-unique-card-4-p-2": "אתם יכולים להשתמש ב-SimpleX עם השרתים שלכם או עם השרתים שסופקו על ידינו — ועדיין להתחבר לכל משתמש.", + "comparison-point-4-text": "רשת בודדת או מרכזית", + "privacy-matters-overlay-card-2-p-2": "כדי להיות אובייקטיבי ולקבל החלטות עצמאיות אתם צריכים להיות בשליטה על מרחב המידע שלכם. זה אפשרי רק אם אתם משתמשים בפלטפורמת תקשורת פרטית שאין לה גישה לגרף החברתי שלכם.", + "simplex-unique-overlay-card-4-p-1": "אתם יכולים להשתמש ב-SimpleX עם השרתים שלכם ועדיין לתקשר עם אנשים שמשתמשים בשרתים המוגדרים מראש שסופקו על ידינו.", + "simplex-chat-for-the-terminal": "SimpleX Chat עבור מסוף שורת הפקודה", + "simplex-network-overlay-card-1-li-3": "P2P אינו פותר את בעיית התקפת MITM, ורוב ההטמעות הקיימות אינן משתמשות בהודעות out-of-band עבור החלפת המפתחות הראשונית. SimpleX משתמש בהודעות out-of-band או, במקרים מסוימים, בחיבורים מאובטחים ומהימנים קיימים מראש לצורך החלפת המפתחות הראשונית.", + "the-instructions--source-code": "ההוראות כיצד להוריד או לקמפל אותו מקוד המקור.", + "simplex-network-section-desc": "Simplex Chat מספק את הפרטיות הטובה ביותר על ידי שילוב היתרונות של P2P ורשתות מאוחדות.", + "privacy-matters-section-subheader": "שמירה על פרטיות המטא נתונים שלכם — עם מי אתם מדברים — מגנה עליכם מפני:", + "if-you-already-installed": "אם כבר התקנתם", + "join": "הצטרפו", + "privacy-matters-section-header": "מדוע פרטיות חשובה", + "protocol-3-text": "פרוטוקולי P2P", + "contact-hero-header": "קיבלת כתובת להתחבר ב-SimpleX Chat", + "contact-hero-subheader": "סרקו את קוד ה-QR עם אפליקציית SimpleX Chat בטלפון או בטאבלט שלכם.", + "join-us-on-GitHub": "הצטרפו אלינו ב-GitHub", + "comparison-section-header": "השוואה לפרוטוקולים אחרים", + "invitation-hero-header": "קיבלת קישור חד-פעמי להתחבר ב-SimpleX Chat", + "simplex-network-1-header": "בניגוד לרשתות P2P", + "hide-info": "הסתר מידע", + "comparison-point-1-text": "דורש זהות גלובלית", + "comparison-point-3-text": "תלות ב-DNS", + "install-simplex-app": "התקינו את אפליקציית SimpleX", + "comparison-point-2-text": "אפשרות של התקפת MITM", + "scan-the-qr-code-with-the-simplex-chat-app-description": "המפתחות הציבוריים וכתובת תור ההודעות בקישור זה אינם נשלחים דרך הרשת כאשר אתם צופים בדף זה —
הם כלולים בקטע הגיבוב (hash) של כתובת הקישור.", + "open-simplex-app": "פתחו את אפליקציית Simplex", + "see-simplex-chat": "ראו SimpleX Chat", + "github-repository": "מאגר GitHub", + "connect-in-app": "התחברו באפליקציה", + "privacy-matters-3-title": "העמדה לדין בשל קשר תמים", + "donate-here-to-help-us": "תרמו כאן כדי לעזור לנו", + "simplex-unique-overlay-card-2-p-2": "אפילו עם כתובת המשתמש האופציונלית, בעוד שניתן להשתמש בה לשליחת בקשות ליצירת קשר דואר זבל, אתה יכול לשנות או למחוק אותה לחלוטין מבלי לאבד אף אחד מהחיבורים שלך.", + "simplex-network-2-header": "בניגוד לרשתות מאוחדות", + "simplex-unique-card-3-p-1": "SimpleX מאחסן את כל נתוני המשתמש במכשירי הלקוח בפורמט מסד נתונים מוצפן נייד — ניתן להעביר אותו למכשיר אחר.", + "contact-hero-p-3": "השתמשו בקישורים למטה כדי להוריד את האפליקציה.", + "simplex-network-1-desc": "כל ההודעות נשלחות דרך השרתים, שמספקים פרטיות טובה יותר של מטא נתונים וגם מסירת הודעות אסינכרונית אמינה, תוך הימנעות מהרבה", + "simplex-unique-overlay-card-1-p-2": "על מנת להעביר הודעות SimpleX משתמש בכתובות אנונימיות בזוגות של תורי הודעות חד-כיווניים, נפרדים עבור הודעות שהתקבלו ונשלחו, בדרך כלל דרך שרתים שונים . השימוש ב-SimpleX דומה לשימוש בדוא\"ל או טלפון ”חד-פעמי“ עבור כל איש קשר, וללא טרחה לנהל אותם.", + "simplex-unique-overlay-card-3-p-4": "אין מזהים או טקסט מוצפן משותף בין תעבורת שרת שנשלחה ומתקבלת — אם מישהו צופה בזה, הם לא יכולים לקבוע בקלות מי מתקשר עם מי, גם אם ה-TLS נפרץ.", + "get-simplex": "הורידו את אפליקציית שולחן העבודה SimpleX", + "privacy-matters-overlay-card-3-p-1": "לכולם צריך להיות אכפת מהפרטיות והאבטחה של התקשורת שלהם — שיחות לא מזיקות עלולות להעמיד אתכם בסכנה, גם אם אין לכם מה להסתיר.", + "simplex-unique-overlay-card-4-p-3": "אם אתם שוקלים לפתח עבור פלטפורמת SimpleX, למשל, את הצ'אט בוט עבור משתמשי אפליקציית SimpleX, או שילוב של ספריית SimpleX Chat באפליקציות לנייד שלכם, אנא צרו קשר לכל עצה ותמיכה.", + "simplex-network-3-header": "רשת SimpleX", + "simplex-unique-card-2-p-1": "מכיוון שאין לכם מזהה או כתובת קבועה בפלטפורמת SimpleX, אף אחד לא יכול ליצור איתכם קשר אלא אם כן אתם חולקים כתובת משתמש חד פעמית או זמנית, כקוד QR או קישור.", + "join-the-REDDIT-community": "הצטרפו לקהילת REDDIT", + "privacy-matters-overlay-card-3-p-3": "אנשים רגילים נעצרים על מה שהם משתפים באינטרנט, אפילו דרך החשבונות ה'אנונימיים' שלהם, אפילו במדינות דמוקרטיות.", + "simplex-unique-overlay-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של SimpleX עד שמתקבלות, ואז הן נמחקות לצמיתות.", + "simplex-unique-overlay-card-4-p-2": "פלטפורמת SimpleX משתמשת בפרוטוקול פתוח ומספקת SDK ליצירת צ'אט בוטים, המאפשר הטמעה של שירותים שמשתמשים יכולים לתקשר איתם באמצעות אפליקציות SimpleX Chat — אנחנו ממש מצפים לראות אילו שירותי SimpleX אתם יכולים לבנות.", + "contact-hero-p-2": "עדיין לא הורדתם את ה-SimpleX Chat?", + "why-simplex-is": "מדוע SimpleX הוא", + "simplex-network-section-header": "רשת SimpleX", + "tap-the-connect-button-in-the-app": "הקישו על הלחצן 'התחבר' באפליקציה", + "unique": "ייחודי", + "simplex-network-1-overlay-linktext": "בעיות של רשתות P2P", + "protocol-2-text": "XMPP, Matrix", + "simplex-network-overlay-card-1-li-4": "יישומי P2P יכולים להיחסם על ידי ספקי אינטרנט מסוימים (כמו BitTorrent). SimpleX הוא אגנוסטי לתעבורה - הוא יכול לעבוד על פרוטוקולי אינטרנט סטנדרטיים, למשל WebSockets.", + "privacy-matters-overlay-card-2-p-1": "לפני זמן לא רב ראינו את הבחירות הראשיות שעברו מניפולציות על ידי חברת ייעוץ מכובדת שהשתמשה בגרפים החברתיים שלנו כדי לעוות את השקפתנו על העולם האמיתי ולתמרן את ההצבעות שלנו.", + "privacy-matters-overlay-card-2-p-3": "SimpleX היא הפלטפורמה הראשונה שאין לה מזהי משתמש על פי עיצובה , בדרך זו מגינה על גרף החיבורים שלך טוב יותר מכל חלופה ידועה.", + "learn-more": "למדו עוד", + "scan-qr-code-from-mobile-app": "סרקו קוד QR באפליקציה לנייד", + "more-info": "עוד מידע", + "protocol-1-text": "Signal, פלטפורמות גדולות", + "simplex-network-overlay-card-1-li-6": "רשתות P2P עשויות להיות פגיעות להתקפת DRDoS , כאשר הלקוחות יכולים לשדר מחדש ולהגביר את התעבורה, וכתוצאה מכך מניעת שירות בכל הרשת. לקוחות SimpleX מעבירים רק תעבורה מחיבור ידוע ואינם יכולים להיות בשימוש על ידי תוקף כדי להגביר את התעבורה בכל הרשת.", + "if-you-already-installed-simplex-chat-for-the-terminal": "אם כבר התקנתם את SimpleX Chat עבור מסוף שורת הפקודה", + "simplex-unique-overlay-card-2-p-1": "מכיוון שבפלטפורמת SimpleX אין לכם מזהים, אף אחד לא יכול ליצור איתכם קשר אלא אם כן אתם משתפים כתובת משתמש חד פעמית או זמנית, כקוד QR או קישור.", + "sign-up-to-receive-our-updates": "הירשמו כדי לקבל את העדכונים שלנו", + "simplex-private-section-header": "מה הופך את SimpleX לפרטי", + "we-invite-you-to-join-the-conversation": "אנו מזמינים אתכם להצטרף לשיחה", + "simplex-unique-overlay-card-1-p-3": "עיצוב זה מגן על הפרטיות של מי שאתה מתקשר איתו, מסתיר אותה משרתי פלטפורמת SimpleX ומכל צופה. כדי להסתיר את כתובת ה-IP שלך מהשרתים, אתה יכוללהתחבר לשרתי SimpleX באמצעות Tor.", + "privacy-matters-section-label": "ודאו שהמסנג'ר שלכם לא יכול לגשת לנתונים שלכם!", + "simplex-unique-overlay-card-3-p-1": "SimpleX Chat מאחסן את כל נתוני המשתמש רק במכשירי הלקוח באמצעות פורמט מסד נתונים מוצפן נייד שניתן לייצא ולהעביר לכל מכשיר נתמך.", + "simplex-network-3-desc": "השרתים מספקים תורים חד-כיווניים לחיבור המשתמשים, אך הם לא יכול לראות את גרף חיבור הרשת — רק המשתמשים יכולים לראות זאת.", + "simplex-unique-card-1-p-1": "SimpleX מגן על פרטיות הפרופיל, אנשי הקשר והמטא נתונים שלכם, מסתיר אותם משרתי פלטפורמת SimpleX ומכל משקיף.", + "simplex-unique-card-4-p-1": "רשת SimpleX מבוזרת לחלוטין ובלתי תלויה במטבעות קריפטוגרפיים כלשהם או כל פלטפורמה אחרת, מלבד האינטרנט.", + "guide-dropdown-5": "ניהול נתונים", + "no-federated": "לא - פדרלית", + "comparison-section-list-point-6": "למרות ש-P2P מופץ, הוא אינו פדרלי - הוא פועל כרשת אחת", + "guide-dropdown-9": "יצירת קשרים", + "comparison-section-list-point-7": "לרשתות P2P יש רשות מרכזית או שכל הרשת יכולה להיפגע", + "docs-dropdown-1": "פלטפורמת SimpleX", + "simplex-private-card-6-point-2": "כדי למנוע זאת, אפליקציות SimpleX מעבירות מפתחות חד-פעמיים מחוץ לפס, כאשר אתם משתפים כתובת כקישור או כקוד QR.", + "no": "לא", + "no-secure": "לא - מאובטח", + "guide-dropdown-3": "קבוצות סודיות", + "no-resilient": "לא - גמיש", + "privacy-matters-overlay-card-3-p-4": "זה לא מספיק להשתמש במסנג'ר מוצפן מקצה לקצה, כולנו צריכים להשתמש במסנג'רים שמגנים על הפרטיות של הרשתות האישיות שלנו — שאליהן אנחנו מחוברים.", + "comparison-section-list-point-5": "אינו מגן על פרטיות המטא נתונים של המשתמשים", + "yes": "כן", + "guide-dropdown-8": "הגדרות אפליקציה", + "comparison-section-list-point-1": "בדרך כלל מבוססת על מספר טלפון, במקרים מסוימים על שמות משתמש", + "comparison-point-5-text": "רכיב מרכזי או מתקפת רשת רחבה", + "simplex-unique-card-1-p-2": "בניגוד לכל פלטפורמת הודעות קיימת אחרת, ל- SimpleX אין מזהים שהוקצו למשתמשים — אפילו לא מספרים אקראיים.", + "guide-dropdown-2": "שליחת הודעות", + "simplex-network-overlay-card-1-li-5": "כל רשתות ה-P2P המוכרות עשויות להיות פגיעות להתקפת Sybil, מכיוון שכל צומת ניתן לגילוי, והרשת פועלת כמכלול. אמצעים ידועים כדי למתן את זה דורשים רכיב ריכוזי או מערכת הוכחת עבודה יקרה. לרשת SimpleX אין יכולת גילוי של שרת, היא מפוצלת ופועלת כמספר תת-רשתות מבודדות, מה שהופך התקפות ברחבי הרשת לבלתי אפשריות.", + "guide-dropdown-4": "פרופילי צ'אט", + "see-here": "ראו כאן", + "comparison-section-list-point-3": "מפתח ציבורי או מזהה ייחודי גלובלי אחר", + "comparison-section-list-point-2": "כתובות מבוססות DNS", + "comparison-section-list-point-4": "אם השרתי המפעיל נפגעים. אמת את קוד האבטחה ב-Signal ובכמה אפליקציות אחרות כדי למתן את זה", + "guide-dropdown-7": "פרטיות ואבטחה", + "simplex-private-card-7-point-1": "על מנת להבטיח את שלמות ההודעות, הן ממוספרות ברצף וכוללות את הגיבוב (hash) של ההודעה הקודמת.", + "comparison-section-list-point-4a": "ממסרי SimpleX אינם יכולים לסכן הצפנה מקצה לקצה. ודא קוד אבטחה כדי לצמצם התקפות על ערוץ \"מחוץ לרשת\"", + "no-private": "לא - פרטי", + "guide": "מדריך", + "guide-dropdown-6": "שיחות שמע ווידאו", + "no-decentralized": "לא - מבוזר", + "simplex-private-card-1-point-1": "פרוטוקול ראצ'ט כפול —
העברת הודעות OTR עם סודיות מושלמת קדימה ושחזור פריצה.", + "simplex-private-card-8-point-1": "שרתי SimpleX פועלים כצמתי ערבוב עם זמן השהיה נמוך — להודעות הנכנסות והיוצאות יש סדר שונה.", + "guide-dropdown-1": "התחלה מהירה", + "privacy-matters-overlay-card-1-p-4": "פלטפורמת SimpleX מגנה על פרטיות החיבורים שלכם טוב יותר מכל חלופה, ומונעת באופן מלא את הפיכת הגרף החברתי להיות זמין לחברות או ארגונים כלשהם. גם כאשר אנשים משתמשים בשרתים המסופקים על ידי SimpleX Chat, איננו יודעים את מספר המשתמשים או החיבורים שלהם.", + "simplex-network-overlay-card-1-li-1": "רשתות P2P מסתמכות על גרסה כלשהי של DHT כדי לנתב הודעות. עיצובי DHT צריכים לאזן בין אחריות מסירה לבין זמן השהיה. ל- SimpleX יש גם אחריות מסירה טובה יותר וגם השהיה נמוכה יותר מאשר P2P, מכיוון שניתן להעביר את ההודעה כיתירות דרך מספר שרתים במקביל, באמצעות השרתים שנבחרו על ידי הנמען. ברשתות P2P ההודעה מועברת דרך O(log N) צמתים ברצף, תוך שימוש בצמתים שנבחרו על ידי האלגוריתם.", + "simplex-unique-overlay-card-1-p-1": "בניגוד לפלטפורמות הודעות אחרות, ל- SimpleX אין מזהים שהוקצו למשתמשים. הוא אינו מסתמך על מספרי טלפון, כתובות מבוססות דומיין (כמו דואר אלקטרוני או XMPP), שמות משתמש, מפתחות ציבוריים או אפילו מספרים אקראיים כדי לזהות את המשתמשים שלו — אנחנו לא יודעים כמה אנשים משתמשים בשרתי SimpleX שלנו.", + "docs-dropdown-5": "כיצד לארח שרת XFTP", + "docs-dropdown-3": "כיצד לגשת למסד הנתונים של צ'אט", + "on-this-page": "בעמוד זה", + "docs-dropdown-6": "שרתי WebRTC", + "newer-version-of-eng-msg": "יש גרסה חדשה יותר של דף זה באנגלית.", + "menu": "תפריט", + "click-to-see": "לחצו כדי לראות", + "docs-dropdown-7": "תרגמו את SimpleX Chat", + "docs-dropdown-2": "כיצד לגשת לקבצי אנדרואיד", + "docs-dropdown-4": "כיצד לארח שרת SMP", + "docs-dropdown-9": "הורדות", + "signing-key-fingerprint": "חתימת מפתח טביעת אצבע (SHA-256)", + "simplex-chat-via-f-droid": "SimpleX Chat דרך F-Droid", + "glossary": "מילון מונחים", + "jobs": "הצטרפו לצוות", + "releases-to-this-repo-are-done-1-2-days-later": "גרסאות למאגר זה משוחררות לאחר יום או יומיים", + "f-droid-org-repo": "מאגר F-Droid.org", + "stable-versions-built-by-f-droid-org": "גרסאות יציבות שנבנו על ידי F-Droid.org", + "back-to-top": "חזרה למעלה", + "simplex-chat-repo": "מאגר SimpleX Chat", + "stable-and-beta-versions-built-by-developers": "גרסאות יציבות ובטא שנבנו על ידי המפתחים", + "f-droid-page-simplex-chat-repo-section-text": "כדי להוסיף אותו ללקוח F-Droid שלכם, סרקו את קוד ה-QR או השתמשו בכתובת האתר הזו:", + "docs-dropdown-8": "שירות מדריך כתובות SimpleX", + "f-droid-page-f-droid-org-repo-section-text": "מאגרי SimpleX Chat ו-F-Droid.org חותמים על גרסאות עם מפתחות שונים. כדי לעבור, אנא ייצא את מסד הנתונים של הצ'אט והתקן מחדש את האפליקציה." } diff --git a/website/langs/it.json b/website/langs/it.json index fa254e66ef..b8a83aee52 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -243,5 +243,12 @@ "simplex-chat-repo": "Repo di SimpleX Chat", "stable-and-beta-versions-built-by-developers": "Versioni stabili e beta compilate dagli sviluppatori", "f-droid-page-simplex-chat-repo-section-text": "Per aggiungerlo al tuo client F-Droid scansiona il codice QR o usa questo URL:", - "comparison-section-list-point-4a": "I relay di SimpleX non possono compromettere la crittografia e2e. Verifica il codice di sicurezza per mitigare gli attacchi sul canale fuori banda" + "comparison-section-list-point-4a": "I relay di SimpleX non possono compromettere la crittografia e2e. Verifica il codice di sicurezza per mitigare gli attacchi sul canale fuori banda", + "hero-overlay-3-title": "Valutazione della sicurezza", + "hero-overlay-card-3-p-2": "Trail of Bits ha revisionato i componenti di crittografia e di rete della piattaforma SimpleX nel novembre 2022.", + "hero-overlay-card-3-p-3": "Maggiori informazioni nell'annuncio.", + "jobs": "Unisciti al team", + "hero-overlay-3-textlink": "Valutazione della sicurezza", + "hero-overlay-card-3-p-1": "Trail of Bits è leader nella consulenza di sicurezza e tecnologia, i cui clienti includono grandi aziende, agenzie governative e importanti progetti di blockchain.", + "docs-dropdown-9": "Download" } diff --git a/website/langs/nl.json b/website/langs/nl.json index 02bf471d3b..0cb3428563 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -243,5 +243,12 @@ "releases-to-this-repo-are-done-1-2-days-later": "De releases voor deze repository vinden 1-2 dagen later plaats", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat- en F-Droid.org-repository's ondertekenen builds met de verschillende sleutels. Om over te stappen, alstublieft exporteer de chatdatabase en installeer de app opnieuw.", "docs-dropdown-8": "SimpleX Directory Service", - "comparison-section-list-point-4a": "SimpleX relais kunnen de e2e-versleuteling niet in gevaar brengen. Controleer de beveiligingscode om aanvallen op out-of-band kanalen te beperken" + "comparison-section-list-point-4a": "SimpleX relais kunnen de e2e-versleuteling niet in gevaar brengen. Controleer de beveiligingscode om aanvallen op out-of-band kanalen te beperken", + "hero-overlay-3-title": "Beveiligings beoordeling", + "hero-overlay-card-3-p-2": "Trail of Bits heeft in november 2022 de cryptografie en netwerkcomponenten van het SimpleX-platform beoordeeld.", + "hero-overlay-card-3-p-3": "Lees meer in de aankondiging.", + "jobs": "Sluit je aan bij het team", + "hero-overlay-3-textlink": "Beveiligings beoordeling", + "hero-overlay-card-3-p-1": "Trail of Bits is een toonaangevend beveiligings- en technologieadviesbureau met klanten onder meer grote technologiebedrijven, overheidsinstanties en grote blockchain-projecten.", + "docs-dropdown-9": "Downloads" } diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index c327e160be..78e55a1f72 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -35,7 +35,7 @@ "simplex-network-1-overlay-linktext": "P2P网络存在的问题", "simplex-network-2-header": "不同于联邦网络", "comparison-point-1-text": "需要全球身份", - "no-secure": "不需要 - 安全", + "no-secure": "不可能 - 安全", "simplex-privacy": "SimpleX 的隐私性", "home": "主页", "developers": "开发者", @@ -185,14 +185,14 @@ "protocol-3-text": "P2P协议", "no": "不需要", "comparison-point-3-text": "对 DNS 的依赖", - "no-federated": "不需要 - 联邦的", + "no-federated": "不依赖 - 联邦式网络", "protocol-2-text": "XMPP、Matrix", "comparison-point-2-text": "中间人攻击的可能性", "comparison-point-5-text": "中央组件或其他全网攻击", "yes": "需要", "comparison-section-list-point-5": "不保护用户的元数据", - "no-resilient": "不需要 - 有抗御力", - "no-decentralized": "不需要 - 去中心化的", + "no-resilient": "不依赖 - 有韧性", + "no-decentralized": "不依赖 - 去中心化的", "comparison-section-list-point-3": "公钥或其他一些全球唯一的 ID", "comparison-section-list-point-4": "如果运营商的服务器受到威胁。 验证 Signal 和其他一些应用程序中的安全代码以缓解该问题", "comparison-section-list-point-1": "通常基于电话号码,在某些情况下基于用户名", @@ -243,5 +243,12 @@ "f-droid-page-simplex-chat-repo-section-text": "要将其添加到您的 F-Droid 客户端,请扫描二维码或使用以下 URL:", "comparison-section-list-point-4a": "SimpleX 中继无法破坏 e2e 加密。 验证安全代码以减轻对带外通道的攻击", "docs-dropdown-8": "SimpleX 目录服务", - "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat 和 F-Droid.org 存储库使用不同的密钥对构建进行签名。 如需切换,请导出聊天数据库并重新安装应用。" + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat 和 F-Droid.org 存储库使用不同的密钥对构建进行签名。 如需切换,请导出聊天数据库并重新安装应用。", + "hero-overlay-3-title": "安全性评估", + "hero-overlay-card-3-p-2": "2022年11月份,Trail of Bits 审核了 SimpleX 平台的密码学和网络部件。", + "hero-overlay-card-3-p-3": "更多内容见 该公告。", + "jobs": "加入团队", + "hero-overlay-3-textlink": "安全性评估", + "hero-overlay-card-3-p-1": "Trail of Bits 是一家领先的安全和技术咨询企业,其客户包括大型科技公司、政府机构和重要的区块链项目。", + "docs-dropdown-9": "下载" } From ae286124aaf21d273829c659fc2ed474dd5c80c7 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Nov 2023 03:27:06 +0800 Subject: [PATCH 172/294] ios: allow sound in silent mode (#3346) Co-authored-by: Avently --- apps/ios/Shared/Model/AudioRecPlay.swift | 24 +++++++++++++++++++ .../Views/Chat/ChatItem/CIVideoView.swift | 11 +++++++++ .../Views/Helpers/VideoPlayerView.swift | 7 +++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Model/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift index 78773f5ea5..a9d0d6c1d9 100644 --- a/apps/ios/Shared/Model/AudioRecPlay.swift +++ b/apps/ios/Shared/Model/AudioRecPlay.swift @@ -61,6 +61,7 @@ class AudioRecorder { await MainActor.run { AppDelegate.keepScreenOn(false) } + try? av.setCategory(AVAudioSession.Category.soloAmbient) logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)") return .error(error.localizedDescription) } @@ -76,6 +77,7 @@ class AudioRecorder { } recordingTimer = nil AppDelegate.keepScreenOn(false) + try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.soloAmbient) } private func checkPermission() async -> Bool { @@ -129,6 +131,9 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { AppDelegate.keepScreenOn(true) guard let time = self.audioPlayer?.currentTime else { return } self.onTimer?(time) + AudioPlayer.changeAudioSession(true) + } else { + AudioPlayer.changeAudioSession(false) } } } @@ -157,6 +162,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { if let player = audioPlayer { player.stop() AppDelegate.keepScreenOn(false) + AudioPlayer.changeAudioSession(false) } audioPlayer = nil if let timer = playbackTimer { @@ -165,6 +171,24 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { playbackTimer = nil } + static func changeAudioSession(_ playback: Bool) { + // When there is a audio recording, setting any other category will disable sound + if AVAudioSession.sharedInstance().category == .playAndRecord { + return + } + if playback { + if AVAudioSession.sharedInstance().category != .playback { + logger.log("AudioSession: playback") + try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: .duckOthers) + } + } else { + if AVAudioSession.sharedInstance().category != .soloAmbient { + logger.log("AudioSession: soloAmbient") + try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.soloAmbient) + } + } + } + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { stop() self.onFinishPlayback?() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index bc7153ed47..be8b25a0fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -9,6 +9,7 @@ import SwiftUI import AVKit import SimpleXChat +import Combine struct CIVideoView: View { @EnvironmentObject var m: ChatModel @@ -28,6 +29,7 @@ struct CIVideoView: View { @State private var showFullScreenPlayer = false @State private var timeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil + @State private var publisher: AnyCancellable? = nil init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding, scrollProxy: ScrollViewProxy?) { self.chatItem = chatItem @@ -294,6 +296,14 @@ struct CIVideoView: View { m.stopPreviousRecPlay = url if let player = fullPlayer { player.play() + var played = false + publisher = player.publisher(for: \.timeControlStatus).sink { status in + if played || status == .playing { + AppDelegate.keepScreenOn(status == .playing) + AudioPlayer.changeAudioSession(status == .playing) + } + played = status == .playing + } fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in player.seek(to: CMTime.zero) player.play() @@ -308,6 +318,7 @@ struct CIVideoView: View { fullScreenTimeObserver = nil fullPlayer?.pause() fullPlayer?.seek(to: CMTime.zero) + publisher?.cancel() } } } diff --git a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift index 416fa0c378..33acf22ebe 100644 --- a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift +++ b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift @@ -38,8 +38,13 @@ struct VideoPlayerView: UIViewRepresentable { player.seek(to: CMTime.zero) player.play() } + var played = false context.coordinator.publisher = player.publisher(for: \.timeControlStatus).sink { status in - AppDelegate.keepScreenOn(status == .playing) + if played || status == .playing { + AppDelegate.keepScreenOn(status == .playing) + AudioPlayer.changeAudioSession(status == .playing) + } + played = status == .playing } return controller.view } From 83aaaa9ada44c6cce4d3775303749d34de425353 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 10 Nov 2023 20:45:41 +0000 Subject: [PATCH 173/294] translation: remove duplicate string --- .../common/src/commonMain/resources/MR/base/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 8630e37e86..170a28f3d6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1642,7 +1642,6 @@ This is your link for group %1$s! Open group Repeat join request? - You are already joining the group via this link! Group already exists! You are already joining the group %1$s. Already joining the group! From f7b4e4b16abcc0b527df0d32543cd5ef48b075d7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 11 Nov 2023 09:36:16 +0000 Subject: [PATCH 174/294] core: 5.4.0.3 --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- stack.yaml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 0ed57ec28c..b074ed540a 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: e9b5a849ab18de085e8c69d239a9706b99bcf787 + tag: 9460551a042ce9dbd3f686576942fade823a6941 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 6f333d9bb1..09e83047e6 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.2 +version: 5.4.0.3 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index adb5d7d7d0..5ddf00d849 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e9b5a849ab18de085e8c69d239a9706b99bcf787" = "0b50mlnzwian4l9kx4niwnj9qkyp21ryc8x9d3il9jkdfxrx8kqi"; + "https://github.com/simplex-chat/simplexmq.git"."9460551a042ce9dbd3f686576942fade823a6941" = "1j5s7h55j6dpmiajdh380mma1jkffbn88qyqfgjn5nx6il2svkmz"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f8540eb6e7..622226a0c2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.2 +version: 5.4.0.3 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/stack.yaml b/stack.yaml index da69b9e905..75d59b0397 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: e9b5a849ab18de085e8c69d239a9706b99bcf787 + commit: 9460551a042ce9dbd3f686576942fade823a6941 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From 11362941fdb9ab6f088287fa8e574475f12d7546 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 11 Nov 2023 12:12:04 +0000 Subject: [PATCH 175/294] 5.4.0-beta.3: iOS 181, Android 160, Desktop 16 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 52 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8366161fc5..e675772e7d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -142,11 +142,11 @@ 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; - 5CF4DF632AF906B1007893ED /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF5E2AF906B1007893ED /* libgmp.a */; }; - 5CF4DF642AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF5F2AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a */; }; - 5CF4DF652AF906B1007893ED /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF602AF906B1007893ED /* libffi.a */; }; - 5CF4DF662AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF612AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a */; }; - 5CF4DF672AF906B1007893ED /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF622AF906B1007893ED /* libgmpxx.a */; }; + 5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF722AFF8D4D007893ED /* libffi.a */; }; + 5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */; }; + 5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */; }; + 5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF752AFF8D4E007893ED /* libgmp.a */; }; + 5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -423,11 +423,11 @@ 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; - 5CF4DF5E2AF906B1007893ED /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF4DF5F2AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a"; sourceTree = ""; }; - 5CF4DF602AF906B1007893ED /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CF4DF612AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a"; sourceTree = ""; }; - 5CF4DF622AF906B1007893ED /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CF4DF722AFF8D4D007893ED /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a"; sourceTree = ""; }; + 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CF4DF752AFF8D4E007893ED /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a"; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -505,13 +505,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CF4DF662AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CF4DF632AF906B1007893ED /* libgmp.a in Frameworks */, - 5CF4DF642AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a in Frameworks */, - 5CF4DF652AF906B1007893ED /* libffi.a in Frameworks */, + 5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */, + 5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CF4DF672AF906B1007893ED /* libgmpxx.a in Frameworks */, + 5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */, + 5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */, + 5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -572,11 +572,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CF4DF602AF906B1007893ED /* libffi.a */, - 5CF4DF5E2AF906B1007893ED /* libgmp.a */, - 5CF4DF622AF906B1007893ED /* libgmpxx.a */, - 5CF4DF5F2AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz-ghc9.6.3.a */, - 5CF4DF612AF906B1007893ED /* libHSsimplex-chat-5.4.0.2-1SJQ8grxltT8Qxvxi331Zz.a */, + 5CF4DF722AFF8D4D007893ED /* libffi.a */, + 5CF4DF752AFF8D4E007893ED /* libgmp.a */, + 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */, + 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */, + 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */, ); path = Libraries; sourceTree = ""; @@ -1482,7 +1482,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1524,7 +1524,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1604,7 +1604,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1636,7 +1636,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1668,7 +1668,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1714,7 +1714,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 180; + CURRENT_PROJECT_VERSION = 181; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index d1ce585346..b16636d0d6 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4-beta.2 -android.version_code=159 +android.version_name=5.4-beta.3 +android.version_code=160 -desktop.version_name=5.4-beta.2 -desktop.version_code=15 +desktop.version_name=5.4-beta.3 +desktop.version_code=16 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From 8b67ff7a00efaf39338ead9eeeec2f49447b2938 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 11 Nov 2023 16:03:12 +0000 Subject: [PATCH 176/294] core: remote error handling (#3347) * core: remote error handling * fix test, show DB errors --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Remote.hs | 61 ++++++++++++++++++-------------------- src/Simplex/Chat/View.hs | 2 ++ tests/RemoteTests.hs | 2 +- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dad00fcefa..88cb8dd25c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1955,7 +1955,7 @@ processChatCommand = \case ListRemoteHosts -> withUser_ $ CRRemoteHostList <$> listRemoteHosts SwitchRemoteHost rh_ -> withUser_ $ CRCurrentRemoteHost <$> switchRemoteHost rh_ StartRemoteHost rh_ -> withUser_ $ do - (remoteHost_, inv) <- startRemoteHost' rh_ + (remoteHost_, inv) <- startRemoteHost rh_ pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv} StopRemoteHost rh_ -> withUser_ $ closeRemoteHost rh_ >> ok_ DeleteRemoteHost rh -> withUser_ $ deleteRemoteHost rh >> ok_ diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index ef5589e5aa..6916a54a0e 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -122,8 +122,8 @@ setNewRemoteHostId rhKey rhId = do Just s -> Right () <$ TM.insert (RHId rhId) s sessions liftEither r -startRemoteHost' :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) -startRemoteHost' rh_ = do +startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) +startRemoteHost rh_ = do (rhKey, multicast, remoteHost_, pairing) <- case rh_ of Just (rhId, multicast) -> do rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId @@ -134,8 +134,9 @@ startRemoteHost' rh_ = do (invitation, rchClient, vars) <- withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast cmdOk <- newEmptyTMVarIO rhsWaitSession <- async $ do + rhKeyVar <- newTVarIO rhKey atomically $ takeTMVar cmdOk - cleanupOnError rchClient $ waitForSession remoteHost_ vars + handleHostError rhKeyVar $ waitForHostSession remoteHost_ rhKey rhKeyVar vars let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} withRemoteHostSession rhKey $ \case RHSessionStarting -> Right ((), RHSessionConnecting rhs) @@ -152,18 +153,15 @@ startRemoteHost' rh_ = do unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ RHEBadVersion appVersion when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding pure hostInfo - cleanupOnError :: ChatMonad m => RCHostClient -> (TMVar RHKey -> m ()) -> m () - cleanupOnError rchClient action = do - currentKey <- newEmptyTMVarIO - action currentKey `catchChatError` \err -> do - logError $ "startRemoteHost'.waitForSession crashed: " <> tshow err + handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () + handleHostError rhKeyVar action = do + action `catchChatError` \err -> do + logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err sessions <- asks remoteHostSessions - atomically $ readTMVar currentKey >>= (`TM.delete` sessions) - liftIO $ cancelHostClient rchClient - waitForSession :: ChatMonad m => Maybe RemoteHostInfo -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> TMVar RHKey -> m () - waitForSession remoteHost_ vars currentKey = do - let rhKey = maybe RHNew (\RemoteHostInfo {remoteHostId} -> RHId remoteHostId) remoteHost_ - atomically $ writeTMVar currentKey rhKey + session_ <- atomically $ readTVar rhKeyVar >>= (`TM.lookupDelete` sessions) + mapM_ (liftIO . cancelRemoteHost) session_ + waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForHostSession remoteHost_ rhKey rhKeyVar vars = do (sessId, vars') <- takeRCStep vars toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' @@ -175,7 +173,7 @@ startRemoteHost' rh_ = do -- update remoteHost with updated pairing rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew - atomically $ writeTMVar currentKey rhKey' + atomically $ writeTVar rhKeyVar rhKey' disconnected <- toIO $ onDisconnected remoteHostId httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo @@ -252,7 +250,7 @@ switchRemoteHost :: ChatMonad m => Maybe RemoteHostId -> m (Maybe RemoteHostInfo switchRemoteHost rhId_ = do rhi_ <- forM rhId_ $ \rhId -> do let rhKey = RHId rhId - rhi <- withError (const $ ChatErrorRemoteHost rhKey RHEMissing) $ (`remoteHostInfo` True) <$> withStore (`getRemoteHost` rhId) + rhi <- (`remoteHostInfo` True) <$> withStore (`getRemoteHost` rhId) active <- chatReadVar remoteHostSessions case M.lookup rhKey active of Just RHSessionConnected {} -> pure rhi @@ -338,19 +336,14 @@ connectRemoteCtrl inv@RCSignedInvitation {invitation = RCInvitation {ca, app}} = cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk - cleanupOnError rcsClient $ waitForSession rc_ ctrlDeviceName rcsClient vars - cleanupOnError rcsClient . updateRemoteCtrlSession $ \case + handleCtrlError "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars + handleCtrlError "connectRemoteCtrl" . updateRemoteCtrlSession $ \case RCSessionStarting -> Right RCSessionConnecting {rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () where - cleanupOnError :: ChatMonad m => RCCtrlClient -> m () -> m () - cleanupOnError rcsClient action = action `catchChatError` \e -> do - logError $ "connectRemoteCtrl crashed with: " <> tshow e - chatWriteVar remoteCtrlSession Nothing -- XXX: can only wipe PendingConfirmation or RCSessionConnecting, which only have rcsClient to cancel - liftIO $ cancelCtrlClient rcsClient - waitForSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () - waitForSession rc_ ctrlName rcsClient vars = do + waitForCtrlSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () + waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- takeRCStep vars let sessionCode = verificationCode uniq toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} @@ -487,7 +480,7 @@ confirmRemoteCtrl _rcId = do -- | Take a look at emoji of tlsunique, commit pairing, and start session server verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteCtrlInfo -verifyRemoteCtrlSession execChatCommand sessCode' = cleanupOnError $ do +verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemoteCtrlSession" $ do (client, ctrlName, sessionCode, vars) <- getRemoteCtrlSession >>= \case RCSessionPendingConfirmation {rcsClient, ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) @@ -514,16 +507,11 @@ verifyRemoteCtrlSession execChatCommand sessCode' = cleanupOnError $ do Just rc@RemoteCtrl {remoteCtrlId} -> do liftIO $ updateCtrlPairingKeys db remoteCtrlId (dhPrivKey rcCtrlPairing) pure rc - cleanupOnError :: ChatMonad m => m a -> m a - cleanupOnError action = action `catchChatError` \e -> do - logError $ "verifyRemoteCtrlSession crashed with: " <> tshow e - withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) -- cancel session threads, if any - throwError e monitor :: ChatMonad m => Async () -> m () monitor server = do res <- waitCatch server logInfo $ "HTTP2 server stopped: " <> tshow res - withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) -- cancel session threads, if any + cancelActiveRemoteCtrl toView CRRemoteCtrlStopped stopRemoteCtrl :: ChatMonad m => m () @@ -531,6 +519,15 @@ stopRemoteCtrl = join . withRemoteCtrlSession_ . maybe (Left $ ChatErrorRemoteCtrl RCEInactive) $ \s -> Right (liftIO $ cancelRemoteCtrl s, Nothing) +handleCtrlError :: ChatMonad m => Text -> m a -> m a +handleCtrlError name action = action `catchChatError` \e -> do + logError $ name <> " remote ctrl error: " <> tshow e + cancelActiveRemoteCtrl + throwError e + +cancelActiveRemoteCtrl :: ChatMonad m => m () +cancelActiveRemoteCtrl = withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) + cancelRemoteCtrl :: RemoteCtrlSession -> IO () cancelRemoteCtrl = \case RCSessionStarting -> pure () diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 177f3400dd..f96857fd4f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1822,6 +1822,8 @@ viewChatError logLevel = \case SEChatItemNotFoundByText text -> ["message not found by text: " <> plain text] SEDuplicateGroupLink g -> ["you already have link for this group, to show: " <> highlight ("/show link #" <> viewGroupName g)] SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> viewGroupName g)] + SERemoteCtrlNotFound rcId -> ["no remote controller " <> sShow rcId] + SERemoteHostNotFound rhId -> ["no remote host " <> sShow rhId] e -> ["chat db error: " <> sShow e] ChatErrorDatabase err -> case err of DBErrorEncrypted -> ["error: chat database is already encrypted"] diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 9c135a81a5..4aaa3b68cb 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -343,7 +343,7 @@ switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \ desktop <## "bob (Bob)" desktop ##> "/switch remote host 123" - desktop <## "remote host 123 error: RHEMissing" + desktop <## "no remote host 123" stopDesktop mobile desktop desktop ##> "/contacts" From 8e3e58cac805a091593b26309ffc3ad9c30edddc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 12 Nov 2023 12:40:13 +0000 Subject: [PATCH 177/294] core: update remote controller name (#3352) --- src/Simplex/Chat/Remote.hs | 10 +++++----- src/Simplex/Chat/Store/Remote.hs | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 6916a54a0e..9acde94049 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -29,13 +29,12 @@ import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isNothing) +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N -import Network.HTTP2.Client (HTTP2Error (..)) import Network.HTTP2.Server (responseStreaming) import qualified Paths_simplex_chat as SC import Simplex.Chat.Archive (archiveFilesFolder) @@ -504,9 +503,10 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot rc_ <- liftIO $ getRemoteCtrlByFingerprint db (ctrlFingerprint rcCtrlPairing) case rc_ of Nothing -> insertRemoteCtrl db ctrlName rcCtrlPairing >>= getRemoteCtrl db - Just rc@RemoteCtrl {remoteCtrlId} -> do - liftIO $ updateCtrlPairingKeys db remoteCtrlId (dhPrivKey rcCtrlPairing) - pure rc + Just rc@RemoteCtrl {ctrlPairing} -> do + let dhPrivKey' = dhPrivKey rcCtrlPairing + liftIO $ updateRemoteCtrl db rc ctrlName dhPrivKey' + pure rc {ctrlName, ctrlPairing = ctrlPairing {dhPrivKey = dhPrivKey'}} monitor :: ChatMonad m => Async () -> m () monitor server = do res <- waitCatch server diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index e12b581252..22eda53c7a 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -130,16 +130,16 @@ toRemoteCtrl (remoteCtrlId, ctrlName, caKey, C.SignedObject caCert, ctrlFingerpr ctrlPairing = RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} } -updateCtrlPairingKeys :: DB.Connection -> RemoteCtrlId -> C.PrivateKeyX25519 -> IO () -updateCtrlPairingKeys db rcId dhPrivKey = +updateRemoteCtrl :: DB.Connection -> RemoteCtrl -> Text -> C.PrivateKeyX25519 -> IO () +updateRemoteCtrl db RemoteCtrl {remoteCtrlId} ctrlDeviceName dhPrivKey = DB.execute db [sql| UPDATE remote_controllers - SET dh_priv_key = ?, prev_dh_priv_key = dh_priv_key + SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key WHERE remote_ctrl_id = ? |] - (dhPrivKey, rcId) + (ctrlDeviceName, dhPrivKey, remoteCtrlId) deleteRemoteCtrlRecord :: DB.Connection -> RemoteCtrlId -> IO () deleteRemoteCtrlRecord db remoteCtrlId = From 5beeff5cb6dbbc93aebe42a37df090e91ae50da8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sun, 12 Nov 2023 16:41:41 +0400 Subject: [PATCH 178/294] core: take chat lock when synchronizing ratchet (#3349) --- src/Simplex/Chat.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5e3d2f0dad..aaa23f77ff 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1256,7 +1256,7 @@ processChatCommand = \case connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRGroupMemberSwitchAborted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive - APISyncContactRatchet contactId force -> withUser $ \user -> do + APISyncContactRatchet contactId force -> withUser $ \user -> withChatLock "syncContactRatchet" $ do ct <- withStore $ \db -> getContact db user contactId case contactConnId ct of Just connId -> do @@ -1264,7 +1264,7 @@ processChatCommand = \case createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct - APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> do + APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> withChatLock "syncGroupMemberRatchet" $ do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId case memberConnId m of Just connId -> do From 92e3f576ca4ba7bfa98f2a0e19d882202dd66483 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 12 Nov 2023 14:40:49 +0000 Subject: [PATCH 179/294] core: return controller app info in response when connecting, validate ID key (#3353) --- src/Simplex/Chat.hs | 5 +++- src/Simplex/Chat/Controller.hs | 8 ++---- src/Simplex/Chat/Remote.hs | 37 +++++++++++++++------------ src/Simplex/Chat/Remote/AppVersion.hs | 9 ++++++- src/Simplex/Chat/Remote/Types.hs | 9 ++++--- src/Simplex/Chat/Store/Remote.hs | 17 +++++------- src/Simplex/Chat/View.hs | 37 ++++++++++++++++----------- tests/RemoteTests.hs | 4 +-- 8 files changed, 70 insertions(+), 56 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 88cb8dd25c..291ca8be36 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1961,7 +1961,10 @@ processChatCommand = \case DeleteRemoteHost rh -> withUser_ $ deleteRemoteHost rh >> ok_ StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath GetRemoteFile rh rf -> withUser_ $ getRemoteFile rh rf >> ok_ - ConnectRemoteCtrl oob -> withUser_ $ connectRemoteCtrl oob >> ok_ + ConnectRemoteCtrl inv -> withUser_ $ do + (rc_, ctrlAppInfo) <- connectRemoteCtrl inv + let remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_ + pure CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion = currentAppVersion} FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl >> ok_ ConfirmRemoteCtrl rc -> withUser_ $ confirmRemoteCtrl rc >> ok_ VerifyRemoteCtrlSession sessId -> withUser_ $ CRRemoteCtrlConnected <$> verifyRemoteCtrlSession (execChatCommand Nothing) sessId diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a9950372bc..b4f69d9082 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -651,10 +651,8 @@ data ChatResponse | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo} -- TODO remove - | CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- TODO remove, unregistered fingerprint, needs confirmation -- TODO is it needed? | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect - | CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo} -- TODO is remove + | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} -- TODO is remove | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped @@ -682,8 +680,6 @@ allowRemoteEvent = \case CRRemoteHostConnected {} -> False CRRemoteHostStopped {} -> False CRRemoteCtrlList {} -> False - CRRemoteCtrlRegistered {} -> False - CRRemoteCtrlAnnounce {} -> False CRRemoteCtrlFound {} -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlSessionCode {} -> False @@ -1086,7 +1082,7 @@ data RemoteCtrlSession rcsWaitSession :: Async () } | RCSessionPendingConfirmation - { ctrlName :: Text, + { ctrlDeviceName :: Text, rcsClient :: RCCtrlClient, tls :: TLS, sessionCode :: Text, diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 9acde94049..0ba5f5feda 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [5, 4, 0, 2] +minRemoteCtrlVersion = AppVersion [5, 4, 0, 3] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [5, 4, 0, 2] +minRemoteHostVersion = AppVersion [5, 4, 0, 3] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version @@ -256,10 +256,9 @@ switchRemoteHost rhId_ = do _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive rhi_ <$ chatWriteVar currentRemoteHost rhId_ --- XXX: replacing hostPairing replaced with sessionActive, could be a ($>) remoteHostInfo :: RemoteHost -> Bool -> RemoteHostInfo -remoteHostInfo RemoteHost {remoteHostId, storePath, hostName} sessionActive = - RemoteHostInfo {remoteHostId, storePath, hostName, sessionActive} +remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionActive = + RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionActive} deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do @@ -325,37 +324,41 @@ findKnownRemoteCtrl :: ChatMonad m => m () findKnownRemoteCtrl = undefined -- do -- | Use provided OOB link as an annouce -connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m () -connectRemoteCtrl inv@RCSignedInvitation {invitation = RCInvitation {ca, app}} = do - (ctrlDeviceName, v) <- parseCtrlAppInfo app +connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrl, CtrlAppInfo) +connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {ca, app}} = handleCtrlError "connectRemoteCtrl" $ do + (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca + mapM_ (validateRemoteCtrl inv) rc_ hostAppInfo <- getHostAppInfo v - (rcsClient, vars) <- withAgent $ \a -> rcConnectCtrlURI a inv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) + (rcsClient, vars) <- withAgent $ \a -> rcConnectCtrlURI a signedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk handleCtrlError "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars - handleCtrlError "connectRemoteCtrl" . updateRemoteCtrlSession $ \case + updateRemoteCtrlSession $ \case RCSessionStarting -> Right RCSessionConnecting {rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () + pure (rc_, ctrlInfo) where + validateRemoteCtrl RCInvitation {idkey} RemoteCtrl {ctrlPairing = RCCtrlPairing {idPubKey}} = + unless (idkey == idPubKey) $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError $ PRERemoteControl RCEIdentity waitForCtrlSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- takeRCStep vars let sessionCode = verificationCode uniq toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} updateRemoteCtrlSession $ \case - RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} + RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} _ -> Left $ ChatErrorRemoteCtrl RCEBadState parseCtrlAppInfo ctrlAppInfo = do - CtrlAppInfo {deviceName, appVersionRange} <- + ctrlInfo@CtrlAppInfo {appVersionRange} <- liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo v <- case compatibleAppVersion hostAppVersionRange appVersionRange of Just (AppCompatible v) -> pure v Nothing -> throwError $ ChatErrorRemoteCtrl $ RCEBadVersion $ maxVersion appVersionRange - pure (deviceName, v) + pure (ctrlInfo, v) getHostAppInfo appVersion = do hostDeviceName <- chatReadVar localDeviceName encryptFiles <- chatReadVar encryptLocalFiles @@ -465,8 +468,8 @@ listRemoteCtrls = do remoteCtrlInfo rc $ activeRcId == Just remoteCtrlId remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo -remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlName} sessionActive = - RemoteCtrlInfo {remoteCtrlId, ctrlName, sessionActive} +remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionActive = + RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} -- XXX: only used for multicast confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () @@ -482,7 +485,7 @@ verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemoteCtrlSession" $ do (client, ctrlName, sessionCode, vars) <- getRemoteCtrlSession >>= \case - RCSessionPendingConfirmation {rcsClient, ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) + RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) _ -> throwError $ ChatErrorRemoteCtrl RCEBadState let verified = sameVerificationCode sessCode' sessionCode liftIO $ confirmCtrlSession client verified @@ -506,7 +509,7 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot Just rc@RemoteCtrl {ctrlPairing} -> do let dhPrivKey' = dhPrivKey rcCtrlPairing liftIO $ updateRemoteCtrl db rc ctrlName dhPrivKey' - pure rc {ctrlName, ctrlPairing = ctrlPairing {dhPrivKey = dhPrivKey'}} + pure rc {ctrlDeviceName = ctrlName, ctrlPairing = ctrlPairing {dhPrivKey = dhPrivKey'}} monitor :: ChatMonad m => Async () -> m () monitor server = do res <- waitCatch server diff --git a/src/Simplex/Chat/Remote/AppVersion.hs b/src/Simplex/Chat/Remote/AppVersion.hs index a8943968d5..e39a64b0a3 100644 --- a/src/Simplex/Chat/Remote/AppVersion.hs +++ b/src/Simplex/Chat/Remote/AppVersion.hs @@ -4,6 +4,7 @@ module Simplex.Chat.Remote.AppVersion ( AppVersionRange (minVersion, maxVersion), + pattern AppVersionRange, AppVersion (..), pattern AppCompatible, mkAppVersionRange, @@ -22,7 +23,7 @@ import qualified Data.Version as V import Simplex.Messaging.Parsers (defaultJSON) import Text.ParserCombinators.ReadP (readP_to_S) -newtype AppVersion = AppVersion V.Version +newtype AppVersion = AppVersion {appVersion :: V.Version} deriving (Eq, Ord, Show) instance ToJSON AppVersion where @@ -40,6 +41,12 @@ data AppVersionRange = AppVRange { minVersion :: AppVersion, maxVersion :: AppVersion } + deriving (Show) + +pattern AppVersionRange :: AppVersion -> AppVersion -> AppVersionRange +pattern AppVersionRange v1 v2 <- AppVRange v1 v2 + +{-# COMPLETE AppVersionRange #-} mkAppVersionRange :: AppVersion -> AppVersion -> AppVersionRange mkAppVersionRange v1 v2 diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 3177ae3ef6..419339e41e 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -96,7 +96,7 @@ data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} -- | Storable/internal remote host data data RemoteHost = RemoteHost { remoteHostId :: RemoteHostId, - hostName :: Text, + hostDeviceName :: Text, storePath :: FilePath, hostPairing :: RCHostPairing } @@ -104,7 +104,7 @@ data RemoteHost = RemoteHost -- | UI-accessible remote host information data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, - hostName :: Text, + hostDeviceName :: Text, storePath :: FilePath, sessionActive :: Bool } @@ -115,14 +115,14 @@ type RemoteCtrlId = Int64 -- | Storable/internal remote controller data data RemoteCtrl = RemoteCtrl { remoteCtrlId :: RemoteCtrlId, - ctrlName :: Text, + ctrlDeviceName :: Text, ctrlPairing :: RCCtrlPairing } -- | UI-accessible remote controller information data RemoteCtrlInfo = RemoteCtrlInfo { remoteCtrlId :: RemoteCtrlId, - ctrlName :: Text, + ctrlDeviceName :: Text, sessionActive :: Bool } deriving (Show) @@ -151,6 +151,7 @@ data CtrlAppInfo = CtrlAppInfo { appVersionRange :: AppVersionRange, deviceName :: Text } + deriving (Show) data HostAppInfo = HostAppInfo { appVersion :: AppVersion, diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 22eda53c7a..ec84860379 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -57,14 +57,14 @@ remoteHostQuery = |] toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519) -> RemoteHost -toRemoteHost (remoteHostId, hostName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) = - RemoteHost {remoteHostId, hostName, storePath, hostPairing} +toRemoteHost (remoteHostId, hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) = + RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing} where hostPairing = RCHostPairing {caKey, caCert, idPrivKey, knownHost = Just knownHost} knownHost = KnownHostPairing {hostFingerprint, hostDhPubKey} updateHostPairing :: DB.Connection -> RemoteHostId -> Text -> C.PublicKeyX25519 -> IO () -updateHostPairing db rhId hostName hostDhPubKey = +updateHostPairing db rhId hostDeviceName hostDhPubKey = DB.execute db [sql| @@ -72,7 +72,7 @@ updateHostPairing db rhId hostName hostDhPubKey = SET host_device_name = ?, host_dh_pub = ? WHERE remote_host_id = ? |] - (hostName, hostDhPubKey, rhId) + (hostDeviceName, hostDhPubKey, rhId) deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) @@ -123,12 +123,9 @@ toRemoteCtrl :: Maybe C.PrivateKeyX25519 ) -> RemoteCtrl -toRemoteCtrl (remoteCtrlId, ctrlName, caKey, C.SignedObject caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey) = - RemoteCtrl - { remoteCtrlId, - ctrlName, - ctrlPairing = RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} - } +toRemoteCtrl (remoteCtrlId, ctrlDeviceName, caKey, C.SignedObject caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey) = + let ctrlPairing = RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} + in RemoteCtrl {remoteCtrlId, ctrlDeviceName, ctrlPairing} updateRemoteCtrl :: DB.Connection -> RemoteCtrl -> Text -> C.PrivateKeyX25519 -> IO () updateRemoteCtrl db RemoteCtrl {remoteCtrlId} ctrlDeviceName dhPrivKey = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f96857fd4f..d1871deb73 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -5,6 +5,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} @@ -32,6 +33,7 @@ import Data.Time (LocalTime (..), TimeOfDay (..), TimeZone (..), utcToLocalTime) import Data.Time.Calendar (addDays) import Data.Time.Clock (UTCTime) import Data.Time.Format (defaultTimeLocale, formatTime) +import qualified Data.Version as V import qualified Network.HTTP.Types as Q import Numeric (showFFloat) import Simplex.Chat (defaultChatConfig, maxImageSize) @@ -43,6 +45,7 @@ import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol import Simplex.Chat.Remote.Types +import Simplex.Chat.Remote.AppVersion (pattern AppVersionRange, AppVersion (..)) import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types @@ -279,7 +282,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRCurrentRemoteHost rhi_ -> [ maybe "Using local profile" - (\RemoteHostInfo {remoteHostId = rhId, hostName} -> "Using remote host " <> sShow rhId <> " (" <> plain hostName <> ")") + (\RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> "Using remote host " <> sShow rhId <> " (" <> plain hostDeviceName <> ")") rhi_ ] CRRemoteHostList hs -> viewRemoteHosts hs @@ -299,21 +302,25 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> - ["remote controller " <> sShow rcId <> " registered"] - CRRemoteCtrlAnnounce fingerprint -> - ["remote controller announced", "connection code:", plain $ strEncode fingerprint] CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, ctrlName} -> - ["remote controller " <> sShow rcId <> " connecting to " <> plain ctrlName] + CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo = CtrlAppInfo {deviceName, appVersionRange = AppVersionRange _ (AppVersion ctrlVersion)}, appVersion = AppVersion v} -> + [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") + <> (if T.null deviceName then "" else plain deviceName <> ", ") + <> ("v" <> plain (V.showVersion ctrlVersion) <> ctrlVersionInfo) + ] + where + ctrlVersionInfo + | ctrlVersion < v = " (older than this app - upgrade controller)" + | ctrlVersion > v = " (newer than this app - upgrade it)" + | otherwise = "" CRRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, "Compare session code with controller and use:", "/verify remote ctrl " <> plain sessionCode -- TODO maybe pass rcId ] - CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlName} -> - ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlName] + CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> + ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] CRRemoteCtrlStopped -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> @@ -1697,21 +1704,21 @@ viewRemoteHosts = \case [] -> ["No remote hosts"] hs -> "Remote hosts: " : map viewRemoteHostInfo hs where - viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostName, sessionActive} = - plain $ tshow remoteHostId <> ". " <> hostName <> if sessionActive then " (active)" else "" + viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionActive} = + plain $ tshow remoteHostId <> ". " <> hostDeviceName <> if sessionActive then " (active)" else "" viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] viewRemoteCtrls = \case [] -> ["No remote controllers"] hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs where - viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlName, sessionActive} = - plain $ tshow remoteCtrlId <> ". " <> ctrlName <> if sessionActive then " (active)" else "" + viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} = + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> if sessionActive then " (active)" else "" -- TODO fingerprint, accepted? viewRemoteCtrl :: RemoteCtrlInfo -> StyledString -viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlName} = - plain $ tshow remoteCtrlId <> ". " <> ctrlName +viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName} = + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName viewChatError :: ChatLogLevel -> ChatError -> [StyledString] viewChatError logLevel = \case diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 4aaa3b68cb..35f7d15b25 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -388,7 +388,7 @@ startRemote mobile desktop = do desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "ok" + mobile <## "connecting new remote controller: My desktop, v5.4.0.3" desktop <## "new remote host connecting" desktop <## "Compare session code with host:" sessId <- getTermLine desktop @@ -406,7 +406,7 @@ startRemoteStored mobile desktop = do desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "ok" + mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" desktop <## "remote host 1 connecting" desktop <## "Compare session code with host:" sessId <- getTermLine desktop From 72b25385ba389fceddf1e7f3e1d3836ffe49471e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 12 Nov 2023 21:43:43 +0000 Subject: [PATCH 180/294] core: event when new remote host added (#3355) --- src/Simplex/Chat/Controller.hs | 6 ++---- src/Simplex/Chat/Remote.hs | 6 ++++-- src/Simplex/Chat/View.hs | 2 +- tests/RemoteTests.hs | 1 + 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index b4f69d9082..84bbe37337 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -422,7 +422,6 @@ data ChatCommand | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) | SetLocalDeviceName Text - -- | CreateRemoteHost -- ^ Configure a new remote host | ListRemoteHosts | StartRemoteHost (Maybe (RemoteHostId, Bool)) -- ^ Start new or known remote host with optional multicast for known host | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host @@ -642,17 +641,17 @@ data ChatResponse | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} - | CRRemoteHostCreated {remoteHost :: RemoteHostInfo} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text} | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} + | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostStopped {remoteHostId :: RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect - | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} -- TODO is remove + | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} | CRRemoteCtrlStopped @@ -675,7 +674,6 @@ data ChatResponse allowRemoteEvent :: ChatResponse -> Bool allowRemoteEvent = \case - CRRemoteHostCreated {} -> False CRRemoteHostList {} -> False CRRemoteHostConnected {} -> False CRRemoteHostStopped {} -> False diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 0ba5f5feda..57dcd33e43 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -172,7 +172,9 @@ startRemoteHost rh_ = do -- update remoteHost with updated pairing rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew - atomically $ writeTVar rhKeyVar rhKey' + when (rhKey' /= rhKey) $ do + atomically $ writeTVar rhKeyVar rhKey' + toView $ CRNewRemoteHost rhi disconnected <- toIO $ onDisconnected remoteHostId httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo @@ -193,7 +195,7 @@ startRemoteHost rh_ = do pure $ remoteHostInfo rh True Just rhi@RemoteHostInfo {remoteHostId} -> do withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' - pure rhi + pure (rhi :: RemoteHostInfo) {sessionActive = True} onDisconnected :: ChatMonad m => RemoteHostId -> m () onDisconnected remoteHostId = do logDebug "HTTP2 client disconnected" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d1871deb73..f3011e4102 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -278,7 +278,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] - CRRemoteHostCreated RemoteHostInfo {remoteHostId} -> ["remote host " <> sShow remoteHostId <> " created"] CRCurrentRemoteHost rhi_ -> [ maybe "Using local profile" @@ -296,6 +295,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe "Compare session code with host:", plain sessionCode ] + CRNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 35f7d15b25..6647971122 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -397,6 +397,7 @@ startRemote mobile desktop = do mobile <## ("/verify remote ctrl " <> sessId) mobile ##> ("/verify remote ctrl " <> sessId) mobile <## "remote controller 1 session started with My desktop" + desktop <## "new remote host 1 added: Mobile" desktop <## "remote host 1 connected" startRemoteStored :: TestCC -> TestCC -> IO () From 338417d963fbd8072cdc3e2432b3a01b0681b959 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 13 Nov 2023 22:06:01 +0800 Subject: [PATCH 181/294] desktop: catch unreadable crypto file (#3359) --- .../simplex/common/platform/Share.android.kt | 17 ++++++++++++++--- .../common/views/chat/item/CIFileView.kt | 8 +++++++- .../common/platform/RecAndPlay.desktop.kt | 1 + .../simplex/common/platform/Share.desktop.kt | 6 +++++- .../views/chat/item/ChatItemView.desktop.kt | 7 ++++++- .../common/views/helpers/Utils.desktop.kt | 11 ++++++++--- 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index cf3fcbaae7..eb6ed0bbf8 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -35,7 +35,12 @@ actual fun shareFile(text: String, fileSource: CryptoFile) { val tmpFile = File(tmpDir, fileSource.filePath) tmpFile.deleteOnExit() ChatModel.filesToDelete.add(tmpFile) - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + try { + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + return + } getAppFileUri(tmpFile.absolutePath) } else { getAppFileUri(fileSource.filePath) @@ -96,15 +101,21 @@ fun saveImage(ciFile: CIFile?) { val outputStream = BufferedOutputStream(stream) if (ciFile.fileSource?.cryptoArgs != null) { createTmpFileAndDelete { tmpFile -> - decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + try { + decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + return@createTmpFileAndDelete + } tmpFile.inputStream().use { it.copyTo(outputStream) } + showToast(generalGetString(MR.strings.image_saved)) } outputStream.close() } else { File(filePath).inputStream().use { it.copyTo(outputStream) } outputStream.close() + showToast(generalGetString(MR.strings.image_saved)) } - showToast(generalGetString(MR.strings.image_saved)) } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 87f4aa4f31..57dcd16cb9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -214,7 +214,13 @@ fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = if (filePath != null && to != null) { if (ciFile?.fileSource?.cryptoArgs != null) { createTmpFileAndDelete { tmpFile -> - decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + try { + decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + tmpFile.delete() + return@createTmpFileAndDelete + } copyFileToFile(tmpFile, to) {} tmpFile.delete() } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 25fc9ec8d5..83351d7729 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -59,6 +59,7 @@ actual object AudioPlayer: AudioPlayerInterface { } }.onFailure { Log.e(TAG, it.stackTraceToString()) + fileSource.deleteTmpFile() AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message) return null } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt index 1d5ab45bbb..a91bc5a761 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -27,7 +27,11 @@ actual fun shareFile(text: String, fileSource: CryptoFile) { FileChooserLauncher(false) { to: URI? -> if (to != null) { if (fileSource.cryptoArgs != null) { - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, to.path) + try { + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, to.path) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + } } else { copyFileToFile(File(fileSource.filePath), to) {} } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index 9df5bd0a12..f602dd577c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -48,7 +48,12 @@ actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { val filePath: String = if (fileSource.cryptoArgs != null) { val tmpFile = File(tmpDir, fileSource.filePath) tmpFile.deleteOnExit() - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + try { + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) + return + } tmpFile.absolutePath } else { getAppFilePath(fileSource.filePath) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index e867dd1b34..7478e22a43 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -94,9 +94,14 @@ actual fun getAppFileUri(fileName: String): URI = actual fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) return if (filePath != null) { - val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() - val bitmap = getBitmapFromByteArray(data, false) - if (bitmap != null) bitmap to data else null + try { + val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() + val bitmap = getBitmapFromByteArray(data, false) + if (bitmap != null) bitmap to data else null + } catch (e: Exception) { + Log.e(TAG, "Unable to read crypto file: " + e.stackTraceToString()) + null + } } else { null } From a2fe5cfb66db24eb2166f480e5dc1cef1bdc1c6c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 13 Nov 2023 17:45:10 +0000 Subject: [PATCH 182/294] core: fix incorrect JSON serialization (#3361) --- src/Simplex/Chat/Messages/CIContent.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 639093d01b..d0be9e2d8c 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -555,7 +555,7 @@ jsonCIContent = \case CIRcvChatFeatureRejected feature -> JCIRcvChatFeatureRejected {feature} CIRcvGroupFeatureRejected groupFeature -> JCIRcvGroupFeatureRejected {groupFeature} CISndModerated -> JCISndModerated - CIRcvModerated -> JCISndModerated + CIRcvModerated -> JCIRcvModerated CIInvalidJSON json -> JCIInvalidJSON (toMsgDirection $ msgDirection @d) json aciContentJSON :: JSONCIContent -> ACIContent From 598b6659ccfe410f1264eb65876b4dfbc1026a3f Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 13 Nov 2023 20:39:41 +0200 Subject: [PATCH 183/294] core: better handling of remote errors (#3358) * Allow ExitCode exceptions to do their job * Use appropriate error type * Close TLS server when cancelling connected remote host * Add timeout errors * Bump simplexmq * extract common timeout value --- cabal.project | 2 +- src/Simplex/Chat.hs | 9 +++++++-- src/Simplex/Chat/Controller.hs | 3 ++- src/Simplex/Chat/Remote.hs | 26 +++++++++++++++++--------- src/Simplex/Chat/Remote/Types.hs | 8 +++++++- 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/cabal.project b/cabal.project index 5730bfb7b9..c9273ea950 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: c051ebab74632e0eb60686329ab3fad521736f79 + tag: 4f5d52ada47a15532766b2ff3d3781be629648d8 source-repository-package type: git diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 60e861a484..8b9abfde5f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -103,7 +103,7 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version -import System.Exit (exitFailure, exitSuccess) +import System.Exit (ExitCode, exitFailure, exitSuccess) import System.FilePath (takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) import System.Random (randomRIO) @@ -411,7 +411,12 @@ execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ChatCommand - execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse -handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catch` (pure . Left . mkChatError)) +handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catches` ioErrors) + where + ioErrors = + [ E.Handler $ \(e :: ExitCode) -> E.throwIO e, + E.Handler $ pure . Left . mkChatError + ] parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 84bbe37337..622ce7b706 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1048,6 +1048,7 @@ data RemoteHostError = RHEMissing -- ^ No remote session matches this identifier | RHEInactive -- ^ A session exists, but not active | RHEBusy -- ^ A session is already running + | RHETimeout | RHEBadState -- ^ Illegal state transition | RHEBadVersion {appVersion :: AppVersion} | RHEDisconnected {reason :: Text} -- TODO should be sent when disconnected? @@ -1059,10 +1060,10 @@ data RemoteCtrlError = RCEInactive -- ^ No session is running | RCEBadState -- ^ A session is in a wrong state for the current operation | RCEBusy -- ^ A session is already running + | RCETimeout | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller | RCEBadInvitation | RCEBadVersion {appVersion :: AppVersion} - | RCEBadVerificationCode -- ^ The code submitted doesn't match session TLSunique | RCEHTTP2Error {http2Error :: Text} -- TODO currently not used | RCEProtocolError {protocolError :: RemoteProtocolError} deriving (Show, Exception) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 57dcd33e43..e819f02240 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -90,6 +90,9 @@ ctrlAppVersionRange = mkAppVersionRange minRemoteHostVersion currentAppVersion hostAppVersionRange :: AppVersionRange hostAppVersionRange = mkAppVersionRange minRemoteCtrlVersion currentAppVersion +networkIOTimeout :: Int +networkIOTimeout = 15000000 + -- * Desktop side getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient @@ -161,9 +164,9 @@ startRemoteHost rh_ = do mapM_ (liftIO . cancelRemoteHost) session_ waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do - (sessId, vars') <- takeRCStep vars + (sessId, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm - (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' + (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello withRemoteHostSession rhKey $ \case @@ -180,7 +183,7 @@ startRemoteHost rh_ = do rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient withRemoteHostSession rhKey' $ \case - RHSessionConfirmed _ RHPendingSession {} -> Right ((), RHSessionConnected {tls, rhClient, pollAction, storePath}) + RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host toView $ CRRemoteHostConnected rhi @@ -216,7 +219,7 @@ closeRemoteHost :: ChatMonad m => RHKey -> m () closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH - join . withRemoteHostSession_ rhKey . maybe (Left $ ChatErrorRemoteCtrl RCEInactive) $ + join . withRemoteHostSession_ rhKey . maybe (Left $ ChatErrorRemoteHost rhKey RHEInactive) $ \s -> Right (liftIO $ cancelRemoteHost s, Nothing) cancelRemoteHost :: RemoteHostSession -> IO () @@ -226,10 +229,11 @@ cancelRemoteHost = \case RHSessionConfirmed tls rhs -> do cancelPendingSession rhs closeConnection tls - RHSessionConnected {tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do + RHSessionConnected {rchClient, tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do uninterruptibleCancel pollAction closeHTTP2Client httpClient closeConnection tls + cancelHostClient rchClient where cancelPendingSession RHPendingSession {rchClient, rhsWaitSession} = do uninterruptibleCancel rhsWaitSession @@ -333,7 +337,8 @@ connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {c rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca mapM_ (validateRemoteCtrl inv) rc_ hostAppInfo <- getHostAppInfo v - (rcsClient, vars) <- withAgent $ \a -> rcConnectCtrlURI a signedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) + (rcsClient, vars) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . withAgent $ \a -> + rcConnectCtrlURI a signedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk @@ -348,7 +353,7 @@ connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {c unless (idkey == idPubKey) $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError $ PRERemoteControl RCEIdentity waitForCtrlSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () waitForCtrlSession rc_ ctrlName rcsClient vars = do - (uniq, tls, rcsWaitConfirmation) <- takeRCStep vars + (uniq, tls, rcsWaitConfirmation) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars let sessionCode = verificationCode uniq toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} updateRemoteCtrlSession $ \case @@ -397,6 +402,9 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque attach send flush +timeoutThrow :: (MonadUnliftIO m, MonadError e m) => e -> Int -> m a -> m a +timeoutThrow e ms action = timeout ms action >>= maybe (throwError e) pure + takeRCStep :: ChatMonad m => RCStepTMVar a -> m a takeRCStep = liftEitherError (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar @@ -490,9 +498,9 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) _ -> throwError $ ChatErrorRemoteCtrl RCEBadState let verified = sameVerificationCode sessCode' sessionCode - liftIO $ confirmCtrlSession client verified + timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . liftIO $ confirmCtrlSession client verified -- signal verification result before crashing unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode - (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- takeRCStep vars + (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars rc@RemoteCtrl {remoteCtrlId} <- upsertRemoteCtrl ctrlName rcCtrlPairing remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO encryption <- mkCtrlRemoteCrypto sessionKeys $ tlsUniq tls diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 419339e41e..17ea8e1599 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -65,7 +65,13 @@ data RemoteHostSession = RHSessionStarting | RHSessionConnecting {rhPendingSession :: RHPendingSession} | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} - | RHSessionConnected {tls :: TLS, rhClient :: RemoteHostClient, pollAction :: Async (), storePath :: FilePath} + | RHSessionConnected + { rchClient :: RCHostClient, + tls :: TLS, + rhClient :: RemoteHostClient, + pollAction :: Async (), + storePath :: FilePath + } data RemoteProtocolError = -- | size prefix is malformed From c91625b32a412514bc48a00dcffd44f8336f2b97 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 13 Nov 2023 20:16:34 +0000 Subject: [PATCH 184/294] core: update remote host session state, terminate TLS in one more case (#3364) * core: update remote host session state, terminate TLS in one more case * name --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Remote.hs | 57 ++++++++++++++++++-------------- src/Simplex/Chat/Remote/Types.hs | 24 ++++++++++++-- src/Simplex/Chat/View.hs | 12 +++++-- stack.yaml | 2 +- tests/RemoteTests.hs | 4 +-- 7 files changed, 69 insertions(+), 34 deletions(-) diff --git a/cabal.project b/cabal.project index c9273ea950..f7102312ce 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 4f5d52ada47a15532766b2ff3d3781be629648d8 + tag: e0b7942e45e36d92625e07c0c1ce9ca2375a0980 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index edc6f2fd23..d7870a87cb 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."c051ebab74632e0eb60686329ab3fad521736f79" = "1j7z3v3vk02nq4sw46flky1l4pjxfiypbwh5s77m6f81rc0vsjvi"; + "https://github.com/simplex-chat/simplexmq.git"."e0b7942e45e36d92625e07c0c1ce9ca2375a0980" = "0swbcrmdirwqrk0kx5jmc5lcrzasccfwn3papb5c1p8hn0hjnzj7"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index e819f02240..bb96107127 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -32,7 +32,7 @@ import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N import Network.HTTP2.Server (responseStreaming) @@ -129,7 +129,7 @@ startRemoteHost rh_ = do (rhKey, multicast, remoteHost_, pairing) <- case rh_ of Just (rhId, multicast) -> do rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId - pure (RHId rhId, multicast, Just $ remoteHostInfo rh True, hostPairing) -- get from the database, start multicast if requested + pure (RHId rhId, multicast, Just $ remoteHostInfo rh $ Just RHSStarting, hostPairing) -- get from the database, start multicast if requested Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing withRemoteHostSession_ rhKey $ maybe (Right ((), Just RHSessionStarting)) (\_ -> Left $ ChatErrorRemoteHost rhKey RHEBusy) ctrlAppInfo <- mkCtrlAppInfo @@ -141,7 +141,9 @@ startRemoteHost rh_ = do handleHostError rhKeyVar $ waitForHostSession remoteHost_ rhKey rhKeyVar vars let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} withRemoteHostSession rhKey $ \case - RHSessionStarting -> Right ((), RHSessionConnecting rhs) + RHSessionStarting -> + let inv = decodeLatin1 $ strEncode invitation + in Right ((), RHSessionConnecting inv rhs) _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState (remoteHost_, invitation) <$ atomically (putTMVar cmdOk ()) where @@ -162,18 +164,22 @@ startRemoteHost rh_ = do sessions <- asks remoteHostSessions session_ <- atomically $ readTVar rhKeyVar >>= (`TM.lookupDelete` sessions) mapM_ (liftIO . cancelRemoteHost) session_ - waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do - (sessId, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite + (sessId, tls, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite + let sessCode = verificationCode sessId + withRemoteHostSession rhKey $ \case + RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessCode tls rhs') -- TODO check it's the same session? + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm - (RCHostSession {tls, sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code + (RCHostSession {sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello withRemoteHostSession rhKey $ \case - RHSessionConnecting rhs' -> Right ((), RHSessionConfirmed tls rhs') -- TODO check it's the same session? + RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- update remoteHost with updated pairing - rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName RHSConfirmed let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' @@ -187,18 +193,18 @@ startRemoteHost rh_ = do _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host toView $ CRRemoteHostConnected rhi - upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> m RemoteHostInfo - upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName = do + upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> RemoteHostSessionState -> m RemoteHostInfo + upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ case rhi_ of Nothing -> do storePath <- liftIO randomStorePath rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath pairing' >>= getRemoteHost db setNewRemoteHostId RHNew remoteHostId - pure $ remoteHostInfo rh True + pure $ remoteHostInfo rh $ Just state Just rhi@RemoteHostInfo {remoteHostId} -> do withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' - pure (rhi :: RemoteHostInfo) {sessionActive = True} + pure (rhi :: RemoteHostInfo) {sessionState = Just state} onDisconnected :: ChatMonad m => RemoteHostId -> m () onDisconnected remoteHostId = do logDebug "HTTP2 client disconnected" @@ -225,7 +231,10 @@ closeRemoteHost rhKey = do cancelRemoteHost :: RemoteHostSession -> IO () cancelRemoteHost = \case RHSessionStarting -> pure () - RHSessionConnecting rhs -> cancelPendingSession rhs + RHSessionConnecting _inv rhs -> cancelPendingSession rhs + RHSessionPendingConfirmation _sessCode tls rhs -> do + cancelPendingSession rhs + closeConnection tls RHSessionConfirmed tls rhs -> do cancelPendingSession rhs closeConnection tls @@ -245,26 +254,26 @@ randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 listRemoteHosts :: ChatMonad m => m [RemoteHostInfo] listRemoteHosts = do - active <- chatReadVar remoteHostSessions - map (rhInfo active) <$> withStore' getRemoteHosts + sessions <- chatReadVar remoteHostSessions + map (rhInfo sessions) <$> withStore' getRemoteHosts where - rhInfo active rh@RemoteHost {remoteHostId} = - remoteHostInfo rh (M.member (RHId remoteHostId) active) + rhInfo sessions rh@RemoteHost {remoteHostId} = + remoteHostInfo rh (rhsSessionState <$> M.lookup (RHId remoteHostId) sessions) switchRemoteHost :: ChatMonad m => Maybe RemoteHostId -> m (Maybe RemoteHostInfo) switchRemoteHost rhId_ = do rhi_ <- forM rhId_ $ \rhId -> do let rhKey = RHId rhId - rhi <- (`remoteHostInfo` True) <$> withStore (`getRemoteHost` rhId) - active <- chatReadVar remoteHostSessions - case M.lookup rhKey active of - Just RHSessionConnected {} -> pure rhi + rh <- withStore (`getRemoteHost` rhId) + sessions <- chatReadVar remoteHostSessions + case M.lookup rhKey sessions of + Just RHSessionConnected {} -> pure $ remoteHostInfo rh $ Just RHSConnected _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive rhi_ <$ chatWriteVar currentRemoteHost rhId_ -remoteHostInfo :: RemoteHost -> Bool -> RemoteHostInfo -remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionActive = - RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionActive} +remoteHostInfo :: RemoteHost -> Maybe RemoteHostSessionState -> RemoteHostInfo +remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionState = + RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionState} deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 17ea8e1599..ce28040481 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -3,6 +3,7 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} @@ -63,7 +64,8 @@ data RHPendingSession = RHPendingSession data RemoteHostSession = RHSessionStarting - | RHSessionConnecting {rhPendingSession :: RHPendingSession} + | RHSessionConnecting {invitation :: Text, rhPendingSession :: RHPendingSession} + | RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS, rhPendingSession :: RHPendingSession} | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} | RHSessionConnected { rchClient :: RCHostClient, @@ -73,6 +75,22 @@ data RemoteHostSession storePath :: FilePath } +data RemoteHostSessionState + = RHSStarting + | RHSConnecting {invitation :: Text} + | RHSPendingConfirmation {sessionCode :: Text} + | RHSConfirmed + | RHSConnected + deriving (Show) + +rhsSessionState :: RemoteHostSession -> RemoteHostSessionState +rhsSessionState = \case + RHSessionStarting -> RHSStarting + RHSessionConnecting {invitation} -> RHSConnecting {invitation} + RHSessionPendingConfirmation {sessionCode} -> RHSPendingConfirmation {sessionCode} + RHSessionConfirmed {} -> RHSConfirmed + RHSessionConnected {} -> RHSConnected + data RemoteProtocolError = -- | size prefix is malformed RPEInvalidSize @@ -112,7 +130,7 @@ data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, hostDeviceName :: Text, storePath :: FilePath, - sessionActive :: Bool + sessionState :: Maybe RemoteHostSessionState } deriving (Show) @@ -174,6 +192,8 @@ $(J.deriveJSON (sumTypeJSON $ dropPrefix "RH") ''RHKey) $(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RHS") ''RemoteHostSessionState) + $(J.deriveJSON defaultJSON ''RemoteHostInfo) $(J.deriveJSON defaultJSON ''RemoteCtrlInfo) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f3011e4102..544614e23b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1704,8 +1704,14 @@ viewRemoteHosts = \case [] -> ["No remote hosts"] hs -> "Remote hosts: " : map viewRemoteHostInfo hs where - viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionActive} = - plain $ tshow remoteHostId <> ". " <> hostDeviceName <> if sessionActive then " (active)" else "" + viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionState} = + plain $ tshow remoteHostId <> ". " <> hostDeviceName <> maybe "" viewSessionState sessionState + viewSessionState = \case + RHSStarting -> " (starting)" + RHSConnecting _ -> " (connecting)" + RHSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" + RHSConfirmed -> " (confirmed)" + RHSConnected -> " (connected)" viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] viewRemoteCtrls = \case @@ -1713,7 +1719,7 @@ viewRemoteCtrls = \case hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs where viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} = - plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> if sessionActive then " (active)" else "" + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> if sessionActive then " (connected)" else "" -- TODO fingerprint, accepted? viewRemoteCtrl :: RemoteCtrlInfo -> StyledString diff --git a/stack.yaml b/stack.yaml index 4fc46bf2b4..befe0b60b5 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: c051ebab74632e0eb60686329ab3fad521736f79 + commit: e0b7942e45e36d92625e07c0c1ce9ca2375a0980 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 6647971122..e3bef7f9e7 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -57,11 +57,11 @@ remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \m desktop ##> "/list remote hosts" desktop <## "Remote hosts:" - desktop <## "1. Mobile (active)" + desktop <## "1. Mobile (connected)" mobile ##> "/list remote ctrls" mobile <## "Remote controllers:" - mobile <## "1. My desktop (active)" + mobile <## "1. My desktop (connected)" if viaDesktop then stopDesktop mobile desktop else stopMobile mobile desktop From 1e8ae6d86114a86932e5e21d26b71d51725fc235 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 14 Nov 2023 09:37:24 +0000 Subject: [PATCH 185/294] docs: update windows app link --- docs/DOWNLOADS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 94b8d9197e..a1576c355e 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -25,7 +25,7 @@ Using the same profile as on mobile device is not yet supported – you need to **Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.0/simplex-desktop-windows-x86-64.msi) (BETA). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.3/simplex-desktop-windows-x86-64.msi) (BETA). ## Mobile apps From 5bbde22ffa02f0bf74187a103856d30c69dea837 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 14 Nov 2023 18:23:05 +0400 Subject: [PATCH 186/294] core: new message decryption error - ratchet synchronization (#3367) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 2 ++ src/Simplex/Chat/Messages/CIContent.hs | 8 +++++++- stack.yaml | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index b074ed540a..4c11d58544 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9460551a042ce9dbd3f686576942fade823a6941 + tag: 7aae6f3cbe4aa2942371f8dd968eb439ccec3b15 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 5ddf00d849..d7940a95c8 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9460551a042ce9dbd3f686576942fade823a6941" = "1j5s7h55j6dpmiajdh380mma1jkffbn88qyqfgjn5nx6il2svkmz"; + "https://github.com/simplex-chat/simplexmq.git"."7aae6f3cbe4aa2942371f8dd968eb439ccec3b15" = "0qx7l1jq0ll1448s077v37s0rv7vlbrdbdgf46x734cl9g0gndk3"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index aaa23f77ff..12936b325a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3635,6 +3635,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do RATCHET_HEADER -> (MDERatchetHeader, 1) RATCHET_EARLIER _ -> (MDERatchetEarlier, 1) RATCHET_SKIPPED n -> (MDETooManySkipped, n) + RATCHET_SYNC -> (MDERatchetSync, 0) mdeUpdatedCI :: (MsgDecryptError, Word32) -> CChatItem c -> Maybe (ChatItem c 'MDRcv, CIContent 'MDRcv) mdeUpdatedCI (mde', n') (CChatItem _ ci@ChatItem {content = CIRcvDecryptionError mde n}) @@ -3643,6 +3644,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do MDETooManySkipped -> r n' -- the numbers are not added as sequential MDETooManySkipped will have it incremented by 1 MDERatchetEarlier -> r (n + n') MDEOther -> r (n + n') + MDERatchetSync -> r 0 | otherwise = Nothing where r n'' = Just (ci, CIRcvDecryptionError mde n'') diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index d0be9e2d8c..ea5c7dfe00 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -150,7 +150,12 @@ ciMsgContent = \case CIRcvMsgContent mc -> Just mc _ -> Nothing -data MsgDecryptError = MDERatchetHeader | MDETooManySkipped | MDERatchetEarlier | MDEOther +data MsgDecryptError + = MDERatchetHeader + | MDETooManySkipped + | MDERatchetEarlier + | MDEOther + | MDERatchetSync deriving (Eq, Show, Generic) instance ToJSON MsgDecryptError where @@ -460,6 +465,7 @@ msgDecryptErrorText err n = MDETooManySkipped -> Just $ "too many skipped messages" <> counter MDERatchetEarlier -> Just $ "earlier message" <> counter MDEOther -> counter_ + MDERatchetSync -> Just "synchronization error" counter_ = if n == 1 then Nothing else Just $ tshow n <> " messages" counter = maybe "" (", " <>) counter_ diff --git a/stack.yaml b/stack.yaml index 75d59b0397..a3ff4342c5 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 9460551a042ce9dbd3f686576942fade823a6941 + commit: 7aae6f3cbe4aa2942371f8dd968eb439ccec3b15 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From 36509a6d796795cf07cccae5bfe548148abe2fa3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 14 Nov 2023 19:39:32 +0400 Subject: [PATCH 187/294] ios, android: new message decryption error - ratchet synchronization (#3368) --- .../ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift | 2 ++ apps/ios/SimpleXChat/ChatTypes.swift | 2 ++ .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 4 +++- .../simplex/common/views/chat/item/CIRcvDecryptionError.kt | 2 ++ .../common/src/commonMain/resources/MR/base/strings.xml | 2 ++ 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index d8a560640e..3ad45d6987 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -165,6 +165,8 @@ struct CIRcvDecryptionError: View { message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why case .other: message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + case .ratchetSync: + message = Text("Encryption re-negotiation failed.") } return message } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f8a6d78a53..551ed2794d 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2676,6 +2676,7 @@ public enum MsgDecryptError: String, Decodable { case tooManySkipped case ratchetEarlier case other + case ratchetSync var text: String { switch self { @@ -2683,6 +2684,7 @@ public enum MsgDecryptError: String, Decodable { case .tooManySkipped: return NSLocalizedString("Permanent decryption error", comment: "message decrypt error item") case .ratchetEarlier: return NSLocalizedString("Decryption error", comment: "message decrypt error item") case .other: return NSLocalizedString("Decryption error", comment: "message decrypt error item") + case .ratchetSync: return NSLocalizedString("Encryption re-negotiation error", comment: "message decrypt error item") } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 91b4a8d8f6..efd7ced3a6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2106,13 +2106,15 @@ enum class MsgDecryptError { @SerialName("ratchetHeader") RatchetHeader, @SerialName("tooManySkipped") TooManySkipped, @SerialName("ratchetEarlier") RatchetEarlier, - @SerialName("other") Other; + @SerialName("other") Other, + @SerialName("ratchetSync") RatchetSync; val text: String get() = when (this) { RatchetHeader -> generalGetString(MR.strings.decryption_error) TooManySkipped -> generalGetString(MR.strings.decryption_error) RatchetEarlier -> generalGetString(MR.strings.decryption_error) Other -> generalGetString(MR.strings.decryption_error) + RatchetSync -> generalGetString(MR.strings.encryption_renegotiation_error) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index ecf7f10dd9..318735d73e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -218,5 +218,7 @@ private fun alertMessage(msgDecryptError: MsgDecryptError, msgCount: UInt): Stri MsgDecryptError.Other -> String.format(generalGetString(MR.strings.alert_text_decryption_error_n_messages_failed_to_decrypt), msgCount.toLong()) + "\n" + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + + MsgDecryptError.RatchetSync -> generalGetString(MR.strings.alert_text_encryption_renegotiation_failed) } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 170a28f3d6..d2f464297b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -45,6 +45,7 @@ invalid chat invalid data Decryption error + Encryption re-negotiation error connection %1$d @@ -866,6 +867,7 @@ %1$d messages failed to decrypt. %1$d messages skipped. It can happen when you or your connection used the old database backup. + Encryption re-negotiation failed. Please report it to the developers. From 0a4920daae4ea8f042fe227d9588d2bd22151489 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 14 Nov 2023 16:44:12 +0000 Subject: [PATCH 188/294] core: encrypt stored/loaded remote files (#3366) * core: encrypt stored/loaded remote files * simplexmq * constant --- src/Simplex/Chat/Remote.hs | 20 +++++++------- src/Simplex/Chat/Remote/Protocol.hs | 18 ++++++------- src/Simplex/Chat/Remote/Transport.hs | 39 ++++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index bb96107127..49c29b6733 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -62,7 +62,6 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (TLS, closeConnection, tlsUniq) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) -import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util import Simplex.RemoteControl.Client @@ -399,8 +398,8 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque processCommand user getNext = \case RCSend {command} -> handleSend execChatCommand command >>= reply RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply - RCStoreFile {fileName, fileSize, fileDigest} -> handleStoreFile fileName fileSize fileDigest getNext >>= reply - RCGetFile {file} -> handleGetFile user file replyWith + RCStoreFile {fileName, fileSize, fileDigest} -> handleStoreFile encryption fileName fileSize fileDigest getNext >>= reply + RCGetFile {file} -> handleGetFile encryption user file replyWith reply :: RemoteResponse -> m () reply = (`replyWith` \_ -> pure ()) replyWith :: Respond m @@ -444,8 +443,8 @@ handleRecv time events = do -- TODO this command could remember stored files and return IDs to allow removing files that are not needed. -- Also, there should be some process removing unused files uploaded to remote host (possibly, all unused files). -handleStoreFile :: forall m. ChatMonad m => FilePath -> Word32 -> FileDigest -> GetChunk -> m RemoteResponse -handleStoreFile fileName fileSize fileDigest getChunk = +handleStoreFile :: forall m. ChatMonad m => RemoteCrypto -> FilePath -> Word32 -> FileDigest -> GetChunk -> m RemoteResponse +handleStoreFile encryption fileName fileSize fileDigest getChunk = either RRProtocolError RRFileStored <$> (chatReadVar filesFolder >>= storeFile) where storeFile :: Maybe FilePath -> m (Either RemoteProtocolError FilePath) @@ -455,11 +454,11 @@ handleStoreFile fileName fileSize fileDigest getChunk = storeFileTo :: FilePath -> m (Either RemoteProtocolError FilePath) storeFileTo dir = liftRC . tryRemoteError $ do filePath <- dir `uniqueCombine` fileName - receiveRemoteFile getChunk fileSize fileDigest filePath + receiveEncryptedFile encryption getChunk fileSize fileDigest filePath pure filePath -handleGetFile :: ChatMonad m => User -> RemoteFile -> Respond m -> m () -handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do +handleGetFile :: ChatMonad m => RemoteCrypto -> User -> RemoteFile -> Respond m -> m () +handleGetFile encryption User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do logDebug $ "GetFile: " <> tshow filePath unless (userId == commandUserId) $ throwChatError $ CEDifferentActiveUser {commandUserId, activeUserId = userId} path <- maybe filePath ( filePath) <$> chatReadVar filesFolder @@ -469,8 +468,9 @@ handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fi liftRC (tryRemoteError $ getFileInfo path) >>= \case Left e -> reply (RRProtocolError e) $ \_ -> pure () Right (fileSize, fileDigest) -> - withFile path ReadMode $ \h -> - reply RRFile {fileSize, fileDigest} $ \send -> hSendFile h send fileSize + withFile path ReadMode $ \h -> do + encFile <- liftRC $ prepareEncryptedFile encryption (h, fileSize) + reply RRFile {fileSize, fileDigest} $ sendEncryptedFile encFile discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> m () discoverRemoteCtrls discovered = do diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index eae71d09c7..c1acee1e0f 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -47,7 +47,6 @@ import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFi import Simplex.Messaging.Transport.Buffer (getBuffered) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) -import Simplex.Messaging.Transport.HTTP2.File (hSendFile) import Simplex.Messaging.Util (liftEitherError, liftEitherWith, liftError, tshow) import Simplex.RemoteControl.Types (CtrlSessKeys (..), HostSessKeys (..), RCErrorType (..), SessionCode) import Simplex.RemoteControl.Client (xrcpBlockSize) @@ -127,31 +126,30 @@ remoteStoreFile c localPath fileName = do r -> badResponse r remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO () -remoteGetFile c destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = +remoteGetFile c@RemoteHostClient{encryption} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = sendRemoteCommand c Nothing RCGetFile {file = rf} >>= \case (getChunk, RRFile {fileSize, fileDigest}) -> do -- TODO we could optimize by checking size and hash before receiving the file let localPath = destDir takeFileName filePath - receiveRemoteFile getChunk fileSize fileDigest localPath + receiveEncryptedFile encryption getChunk fileSize fileDigest localPath (_, r) -> badResponse r --- TODO validate there is no attachment +-- TODO validate there is no attachment in response sendRemoteCommand' :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO RemoteResponse sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) -sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} attachment_ cmd = do - req <- httpRequest <$> encryptEncodeHTTP2Body encryption (J.encode cmd) +sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do + encFile_ <- mapM (prepareEncryptedFile encryption) file_ + req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd) HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing (header, getNext) <- parseDecryptHTTP2Body encryption response respBody rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecode header >>= JT.parseEither J.parseJSON . convertJSON hostEncoding localEncoding pure (getNext, rr) where - httpRequest cmdBld = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do + httpRequest encFile_ cmdBld = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do send cmdBld - case attachment_ of - Nothing -> pure () - Just (h, sz) -> hSendFile h send sz + forM_ encFile_ (`sendEncryptedFile` send) flush badResponse :: RemoteResponse -> ExceptT RemoteProtocolError IO a diff --git a/src/Simplex/Chat/Remote/Transport.hs b/src/Simplex/Chat/Remote/Transport.hs index bf798444c0..c5ddfbdb8f 100644 --- a/src/Simplex/Chat/Remote/Transport.hs +++ b/src/Simplex/Chat/Remote/Transport.hs @@ -1,23 +1,52 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + module Simplex.Chat.Remote.Transport where import Control.Monad import Control.Monad.Except +import Data.ByteString.Builder (Builder, byteString) import Data.ByteString (ByteString) import qualified Data.ByteString.Lazy as LB import Data.Word (Word32) import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Chat.Remote.Types +import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Lazy as LC -import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile) +import Simplex.FileTransfer.Transport (ReceiveFileError (..), receiveSbFile, sendEncFile) +import Simplex.Messaging.Encoding +import Simplex.Messaging.Util (liftEitherError, liftEitherWith) +import Simplex.RemoteControl.Types (RCErrorType (..)) import UnliftIO import UnliftIO.Directory (getFileSize) -receiveRemoteFile :: (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO () -receiveRemoteFile getChunk fileSize fileDigest toPath = do - diff <- liftIO $ withFile toPath WriteMode $ \h -> hReceiveFile getChunk h fileSize - unless (diff == 0) $ throwError RPEFileSize +type EncryptedFile = ((Handle, Word32), C.CbNonce, LC.SbState) + +prepareEncryptedFile :: RemoteCrypto -> (Handle, Word32) -> ExceptT RemoteProtocolError IO EncryptedFile +prepareEncryptedFile RemoteCrypto {drg, hybridKey} f = do + nonce <- atomically $ C.pseudoRandomCbNonce drg + sbState <- liftEitherWith (const $ PRERemoteControl RCEEncrypt) $ LC.kcbInit hybridKey nonce + pure (f, nonce, sbState) + +sendEncryptedFile :: EncryptedFile -> (Builder -> IO ()) -> IO () +sendEncryptedFile ((h, sz), nonce, sbState) send = do + send $ byteString $ smpEncode ('\x01', nonce, sz + fromIntegral C.authTagSize) + sendEncFile h send sbState sz + +receiveEncryptedFile :: RemoteCrypto -> (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO () +receiveEncryptedFile RemoteCrypto {hybridKey} getChunk fileSize fileDigest toPath = do + c <- liftIO $ getChunk 1 + unless (c == "\x01") $ throwError RPENoFile + nonce <- liftEitherError RPEInvalidBody $ smpDecode <$> getChunk 24 + size <- liftEitherError RPEInvalidBody $ smpDecode <$> getChunk 4 + unless (size == fileSize + fromIntegral C.authTagSize) $ throwError RPEFileSize + sbState <- liftEitherWith (const $ PRERemoteControl RCEDecrypt) $ LC.kcbInit hybridKey nonce + liftEitherError fErr $ withFile toPath WriteMode $ \h -> receiveSbFile getChunk h sbState fileSize digest <- liftIO $ LC.sha512Hash <$> LB.readFile toPath unless (FileDigest digest == fileDigest) $ throwError RPEFileDigest + where + fErr RFESize = RPEFileSize + fErr RFECrypto = PRERemoteControl RCEDecrypt getFileInfo :: FilePath -> ExceptT RemoteProtocolError IO (Word32, FileDigest) getFileInfo filePath = do From d4ba1bbe6966edda2e170f2a585d3d1fbbea1b1c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 14 Nov 2023 22:27:21 +0000 Subject: [PATCH 189/294] core: update remote host session state (#3371) --- src/Simplex/Chat/Remote.hs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 49c29b6733..32ffaee919 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -170,7 +170,9 @@ startRemoteHost rh_ = do withRemoteHostSession rhKey $ \case RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessCode tls rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState - toView $ CRRemoteHostSessionCode {remoteHost_, sessionCode = verificationCode sessId} -- display confirmation code, wait for mobile to confirm + -- display confirmation code, wait for mobile to confirm + let rh_' = (\rh -> rh {sessionState = Just $ RHSPendingConfirmation sessCode}) <$> remoteHost_ + toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode = verificationCode sessId} (RCHostSession {sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello @@ -178,7 +180,7 @@ startRemoteHost rh_ = do RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- update remoteHost with updated pairing - rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' remoteHost_ hostDeviceName RHSConfirmed + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName RHSConfirmed let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' @@ -191,7 +193,7 @@ startRemoteHost rh_ = do RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host - toView $ CRRemoteHostConnected rhi + toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected} upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> RemoteHostSessionState -> m RemoteHostInfo upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ From 3d617bce25dd0bf9471c13e96d3e5a8680806a27 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 14 Nov 2023 22:40:15 +0000 Subject: [PATCH 190/294] core: test JSON conversion (#3370) --- src/Simplex/Chat/Controller.hs | 2 +- tests/JSONTests.hs | 35 +++++++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 622ce7b706..3144c909df 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1200,7 +1200,7 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RH") ''RemoteHostError) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCE") ''RemoteCtrlError) diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 188fe27597..a17a69fae8 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -1,10 +1,14 @@ {-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE TemplateHaskell #-} module JSONTests where +import Control.Monad (join) import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT +import Data.ByteString.Builder (toLazyByteString) import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) @@ -15,11 +19,6 @@ import Test.Hspec import Test.Hspec.QuickCheck (modifyMaxSuccess) import Test.QuickCheck (Arbitrary (..), property) -jsonTests :: Spec -jsonTests = describe "owsf2tagged" $ do - it "should convert chat types" owsf2TaggedJSONTest - describe "SomeType" owsf2TaggedSomeTypeTests - owsf2TaggedJSONTest :: IO () owsf2TaggedJSONTest = do noActiveUserSwift `to` noActiveUserTagged @@ -50,6 +49,17 @@ data SomeType | List [Int] deriving (Eq, Show, Generic) +$(pure []) + +thToJSON :: SomeType -> J.Value +thToJSON = $(JQ.mkToJSON (singleFieldJSON_ (Just SingleFieldJSONTag) id) ''SomeType) + +thToEncoding :: SomeType -> J.Encoding +thToEncoding = $(JQ.mkToEncoding (singleFieldJSON_ (Just SingleFieldJSONTag) id) ''SomeType) + +thParseJSON :: J.Value -> JT.Parser SomeType +thParseJSON = $(JQ.mkParseJSON (taggedObjectJSON id) ''SomeType) + instance Arbitrary SomeType where arbitrary = genericArbitraryU instance ToJSON SomeType where @@ -60,6 +70,17 @@ instance FromJSON SomeType where parseJSON = J.genericParseJSON $ taggedObjectJSON id owsf2TaggedSomeTypeTests :: Spec -owsf2TaggedSomeTypeTests = - modifyMaxSuccess (const 10000) $ it "should convert to tagged" $ property $ \x -> +owsf2TaggedSomeTypeTests = modifyMaxSuccess (const 10000) $ do + it "should convert to tagged" $ property $ \x -> (JT.parseMaybe J.parseJSON . owsf2tagged . J.toJSON) x == Just (x :: SomeType) + it "should convert to tagged via encoding" $ property $ \x -> + (join . fmap (JT.parseMaybe J.parseJSON . owsf2tagged) . J.decode . J.encode) x == Just (x :: SomeType) + it "should convert to tagged via TH" $ property $ \x -> + (JT.parseMaybe thParseJSON . owsf2tagged . thToJSON) x == Just (x :: SomeType) + it "should convert to tagged via TH encoding" $ property $ \x -> + (join . fmap (JT.parseMaybe thParseJSON . owsf2tagged) . J.decode . toLazyByteString . J.fromEncoding . thToEncoding) x == Just (x :: SomeType) + +jsonTests :: Spec +jsonTests = describe "owsf2tagged" $ do + it "should convert chat types" owsf2TaggedJSONTest + describe "SomeType" owsf2TaggedSomeTypeTests From 975f6d488ed1ce6fc8d110e621072f23fd1484b0 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 15 Nov 2023 11:46:45 +0400 Subject: [PATCH 191/294] android: fix group join via invitation chat item (#3372) --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 42e43f7b7a..7d83693f04 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -894,7 +894,7 @@ fun BoxWithConstraintsScope.ChatItemsList( @Composable fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) } @Composable From fa9d61caa4ec4633e843d7c0d9540818b7c38a37 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 15 Nov 2023 15:09:52 +0200 Subject: [PATCH 192/294] remove host store in deleteRemoteHost (#3373) --- src/Simplex/Chat/Remote.hs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 32ffaee919..05183c1d8d 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -70,7 +70,7 @@ import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO import UnliftIO.Concurrent (forkIO) -import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile) +import UnliftIO.Directory (copyFile, createDirectoryIfMissing, removeDirectoryRecursive, renameFile) -- when acting as host minRemoteCtrlVersion :: AppVersion @@ -282,7 +282,8 @@ deleteRemoteHost rhId = do chatReadVar filesFolder >>= \case Just baseDir -> do let hostStore = baseDir storePath - logError $ "TODO: remove " <> tshow hostStore + logInfo $ "removing host store at " <> tshow hostStore + removeDirectoryRecursive $ hostStore Nothing -> logWarn "Local file store not available while deleting remote host" withStore' (`deleteRemoteHostRecord` rhId) From b71daed3ec02cc139d2639fc2cf821fdad8ac588 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 15 Nov 2023 13:17:31 +0000 Subject: [PATCH 193/294] core: include session code in all session states (#3374) --- src/Simplex/Chat.hs | 3 +- src/Simplex/Chat/Controller.hs | 32 ++++++++++++++++++-- src/Simplex/Chat/Remote.hs | 52 ++++++++++++++++++-------------- src/Simplex/Chat/Remote/Types.hs | 27 ++++++++--------- src/Simplex/Chat/View.hs | 13 +++++--- 5 files changed, 82 insertions(+), 45 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 619f93b891..b6b7cca1a6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1967,8 +1967,7 @@ processChatCommand = \case StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath GetRemoteFile rh rf -> withUser_ $ getRemoteFile rh rf >> ok_ ConnectRemoteCtrl inv -> withUser_ $ do - (rc_, ctrlAppInfo) <- connectRemoteCtrl inv - let remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_ + (remoteCtrl_, ctrlAppInfo) <- connectRemoteCtrl inv pure CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion = currentAppVersion} FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl >> ok_ ConfirmRemoteCtrl rc -> withUser_ $ confirmRemoteCtrl rc >> ok_ diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3144c909df..fb03844f89 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1077,11 +1077,13 @@ data ArchiveError data RemoteCtrlSession = RCSessionStarting | RCSessionConnecting - { rcsClient :: RCCtrlClient, + { remoteCtrlId_ :: Maybe RemoteCtrlId, + rcsClient :: RCCtrlClient, rcsWaitSession :: Async () } | RCSessionPendingConfirmation - { ctrlDeviceName :: Text, + { remoteCtrlId_ :: Maybe RemoteCtrlId, + ctrlDeviceName :: Text, rcsClient :: RCCtrlClient, tls :: TLS, sessionCode :: Text, @@ -1097,6 +1099,28 @@ data RemoteCtrlSession remoteOutputQ :: TBQueue ChatResponse } +data RemoteCtrlSessionState + = RCSStarting + | RCSConnecting + | RCSPendingConfirmation {sessionCode :: Text} + | RCSConnected {sessionCode :: Text} + deriving (Show) + +rcsSessionState :: RemoteCtrlSession -> RemoteCtrlSessionState +rcsSessionState = \case + RCSessionStarting -> RCSStarting + RCSessionConnecting {} -> RCSConnecting + RCSessionPendingConfirmation {tls} -> RCSPendingConfirmation {sessionCode = tlsSessionCode tls} + RCSessionConnected {tls} -> RCSConnected {sessionCode = tlsSessionCode tls} + +-- | UI-accessible remote controller information +data RemoteCtrlInfo = RemoteCtrlInfo + { remoteCtrlId :: RemoteCtrlId, + ctrlDeviceName :: Text, + sessionState :: Maybe RemoteCtrlSessionState + } + deriving (Show) + type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) @@ -1259,6 +1283,10 @@ instance ToJSON AUserProtoServers where toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) + +$(JQ.deriveJSON defaultJSON ''RemoteCtrlInfo) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 05183c1d8d..16044ee923 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -166,13 +166,13 @@ startRemoteHost rh_ = do waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do (sessId, tls, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite - let sessCode = verificationCode sessId + let sessionCode = verificationCode sessId withRemoteHostSession rhKey $ \case - RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessCode tls rhs') -- TODO check it's the same session? + RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- display confirmation code, wait for mobile to confirm - let rh_' = (\rh -> rh {sessionState = Just $ RHSPendingConfirmation sessCode}) <$> remoteHost_ - toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode = verificationCode sessId} + let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just $ RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ + toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} (RCHostSession {sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello @@ -180,7 +180,7 @@ startRemoteHost rh_ = do RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') -- TODO check it's the same session? _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState -- update remoteHost with updated pairing - rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName RHSConfirmed + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName RHSConfirmed {sessionCode} let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' @@ -193,7 +193,7 @@ startRemoteHost rh_ = do RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host - toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected} + toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> RemoteHostSessionState -> m RemoteHostInfo upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ @@ -268,7 +268,7 @@ switchRemoteHost rhId_ = do rh <- withStore (`getRemoteHost` rhId) sessions <- chatReadVar remoteHostSessions case M.lookup rhKey sessions of - Just RHSessionConnected {} -> pure $ remoteHostInfo rh $ Just RHSConnected + Just RHSessionConnected {tls} -> pure $ remoteHostInfo rh $ Just RHSConnected {sessionCode = tlsSessionCode tls} _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive rhi_ <$ chatWriteVar currentRemoteHost rhId_ @@ -341,7 +341,7 @@ findKnownRemoteCtrl :: ChatMonad m => m () findKnownRemoteCtrl = undefined -- do -- | Use provided OOB link as an annouce -connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrl, CtrlAppInfo) +connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {ca, app}} = handleCtrlError "connectRemoteCtrl" $ do (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) @@ -355,10 +355,10 @@ connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {c atomically $ takeTMVar cmdOk handleCtrlError "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars updateRemoteCtrlSession $ \case - RCSessionStarting -> Right RCSessionConnecting {rcsClient, rcsWaitSession} + RCSessionStarting -> Right RCSessionConnecting {remoteCtrlId_ = remoteCtrlId' <$> rc_, rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () - pure (rc_, ctrlInfo) + pure ((`remoteCtrlInfo` Just RCSConnecting) <$> rc_, ctrlInfo) where validateRemoteCtrl RCInvitation {idkey} RemoteCtrl {ctrlPairing = RCCtrlPairing {idPubKey}} = unless (idkey == idPubKey) $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError $ PRERemoteControl RCEIdentity @@ -366,10 +366,12 @@ connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {c waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars let sessionCode = verificationCode uniq - toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` True) <$> rc_, sessionCode} updateRemoteCtrlSession $ \case - RCSessionConnecting {rcsWaitSession} -> Right RCSessionPendingConfirmation {ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} + RCSessionConnecting {rcsWaitSession} -> + let remoteCtrlId_ = remoteCtrlId' <$> rc_ + in Right RCSessionPendingConfirmation {remoteCtrlId_, ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} _ -> Left $ ChatErrorRemoteCtrl RCEBadState + toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` Just RCSPendingConfirmation {sessionCode}) <$> rc_, sessionCode} parseCtrlAppInfo ctrlAppInfo = do ctrlInfo@CtrlAppInfo {appVersionRange} <- liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo @@ -481,17 +483,23 @@ discoverRemoteCtrls discovered = do listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do - active <- chatReadVar remoteCtrlSession >>= \case - Just RCSessionConnected {remoteCtrlId} -> pure $ Just remoteCtrlId - _ -> pure Nothing - map (rcInfo active) <$> withStore' getRemoteCtrls + session <- chatReadVar remoteCtrlSession + let rcId = sessionRcId =<< session + sessState = rcsSessionState <$> session + map (rcInfo rcId sessState) <$> withStore' getRemoteCtrls where - rcInfo activeRcId rc@RemoteCtrl {remoteCtrlId} = - remoteCtrlInfo rc $ activeRcId == Just remoteCtrlId + rcInfo :: Maybe RemoteCtrlId -> Maybe RemoteCtrlSessionState -> RemoteCtrl -> RemoteCtrlInfo + rcInfo rcId sessState rc@RemoteCtrl {remoteCtrlId} = + remoteCtrlInfo rc $ if rcId == Just remoteCtrlId then sessState else Nothing + sessionRcId = \case + RCSessionConnecting {remoteCtrlId_} -> remoteCtrlId_ + RCSessionPendingConfirmation {remoteCtrlId_} -> remoteCtrlId_ + RCSessionConnected {remoteCtrlId} -> Just remoteCtrlId + _ -> Nothing -remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo -remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionActive = - RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} +remoteCtrlInfo :: RemoteCtrl -> Maybe RemoteCtrlSessionState -> RemoteCtrlInfo +remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionState = + RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} -- XXX: only used for multicast confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () @@ -521,7 +529,7 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot withRemoteCtrlSession $ \case RCSessionPendingConfirmation {} -> Right ((), RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, tls, http2Server, remoteOutputQ}) _ -> Left $ ChatErrorRemoteCtrl RCEBadState - pure $ remoteCtrlInfo rc True + pure $ remoteCtrlInfo rc $ Just RCSConnected {sessionCode = tlsSessionCode tls} where upsertRemoteCtrl :: ChatMonad m => Text -> RCCtrlPairing -> m RemoteCtrl upsertRemoteCtrl ctrlName rcCtrlPairing = withStore $ \db -> do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index ce28040481..c56b2462b0 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -19,6 +19,7 @@ import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) import Simplex.Chat.Remote.AppVersion +import Simplex.Chat.Types (verificationCode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) @@ -26,7 +27,7 @@ import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Types import Simplex.Messaging.Crypto.File (CryptoFile) -import Simplex.Messaging.Transport (TLS) +import Simplex.Messaging.Transport (TLS (..)) data RemoteHostClient = RemoteHostClient { hostEncoding :: PlatformEncoding, @@ -79,17 +80,20 @@ data RemoteHostSessionState = RHSStarting | RHSConnecting {invitation :: Text} | RHSPendingConfirmation {sessionCode :: Text} - | RHSConfirmed - | RHSConnected + | RHSConfirmed {sessionCode :: Text} + | RHSConnected {sessionCode :: Text} deriving (Show) rhsSessionState :: RemoteHostSession -> RemoteHostSessionState rhsSessionState = \case RHSessionStarting -> RHSStarting RHSessionConnecting {invitation} -> RHSConnecting {invitation} - RHSessionPendingConfirmation {sessionCode} -> RHSPendingConfirmation {sessionCode} - RHSessionConfirmed {} -> RHSConfirmed - RHSessionConnected {} -> RHSConnected + RHSessionPendingConfirmation {tls} -> RHSPendingConfirmation {sessionCode = tlsSessionCode tls} + RHSessionConfirmed {tls} -> RHSConfirmed {sessionCode = tlsSessionCode tls} + RHSessionConnected {tls} -> RHSConnected {sessionCode = tlsSessionCode tls} + +tlsSessionCode :: TLS -> Text +tlsSessionCode = verificationCode . tlsUniq data RemoteProtocolError = -- | size prefix is malformed @@ -143,13 +147,8 @@ data RemoteCtrl = RemoteCtrl ctrlPairing :: RCCtrlPairing } --- | UI-accessible remote controller information -data RemoteCtrlInfo = RemoteCtrlInfo - { remoteCtrlId :: RemoteCtrlId, - ctrlDeviceName :: Text, - sessionActive :: Bool - } - deriving (Show) +remoteCtrlId' :: RemoteCtrl -> RemoteCtrlId +remoteCtrlId' = remoteCtrlId data PlatformEncoding = PESwift @@ -196,8 +195,6 @@ $(J.deriveJSON (sumTypeJSON $ dropPrefix "RHS") ''RemoteHostSessionState) $(J.deriveJSON defaultJSON ''RemoteHostInfo) -$(J.deriveJSON defaultJSON ''RemoteCtrlInfo) - $(J.deriveJSON defaultJSON ''CtrlAppInfo) $(J.deriveJSON defaultJSON ''HostAppInfo) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 544614e23b..98a3edd478 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1710,16 +1710,21 @@ viewRemoteHosts = \case RHSStarting -> " (starting)" RHSConnecting _ -> " (connecting)" RHSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" - RHSConfirmed -> " (confirmed)" - RHSConnected -> " (connected)" + RHSConfirmed _ -> " (confirmed)" + RHSConnected _ -> " (connected)" viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] viewRemoteCtrls = \case [] -> ["No remote controllers"] hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs where - viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionActive} = - plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> if sessionActive then " (connected)" else "" + viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} = + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> maybe "" viewSessionState sessionState + viewSessionState = \case + RCSStarting -> " (starting)" + RCSConnecting -> " (connecting)" + RCSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" + RCSConnected _ -> " (connected)" -- TODO fingerprint, accepted? viewRemoteCtrl :: RemoteCtrlInfo -> StyledString From a75fce8dfa30e86a9b61c175ca1db04e49fcb77f Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 15 Nov 2023 17:57:29 +0200 Subject: [PATCH 194/294] Fix hostStore path and check before removing (#3375) --- src/Simplex/Chat/Remote.hs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 16044ee923..b3d2d0ebf0 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -70,7 +70,7 @@ import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO import UnliftIO.Concurrent (forkIO) -import UnliftIO.Directory (copyFile, createDirectoryIfMissing, removeDirectoryRecursive, renameFile) +import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive, renameFile) -- when acting as host minRemoteCtrlVersion :: AppVersion @@ -279,11 +279,11 @@ remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionState deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do RemoteHost {storePath} <- withStore (`getRemoteHost` rhId) - chatReadVar filesFolder >>= \case + chatReadVar remoteHostsFolder >>= \case Just baseDir -> do let hostStore = baseDir storePath logInfo $ "removing host store at " <> tshow hostStore - removeDirectoryRecursive $ hostStore + whenM (doesDirectoryExist hostStore) $ removeDirectoryRecursive hostStore Nothing -> logWarn "Local file store not available while deleting remote host" withStore' (`deleteRemoteHostRecord` rhId) From 339c3d2be187c843ab63a0eb519c04ed987d271c Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 15 Nov 2023 19:31:36 +0200 Subject: [PATCH 195/294] Send CRRemote*Stopped on all errors (#3376) * Send CRRemote*Stopped on all errors Commands use the same action, made idempotent and don't send events. * fix tests * get http2 cancelling back --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Remote.hs | 64 +++++++++++++++++++--------------- src/Simplex/Chat/View.hs | 4 ++- tests/RemoteTests.hs | 20 +++-------- 5 files changed, 45 insertions(+), 47 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b6b7cca1a6..ea16785e44 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -375,7 +375,7 @@ restoreCalls = do stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do - readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost) + readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost True) atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fb03844f89..68d909f784 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -647,7 +647,7 @@ data ChatResponse | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} - | CRRemoteHostStopped {remoteHostId :: RemoteHostId} + | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index b3d2d0ebf0..23d74404d5 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -157,12 +157,9 @@ startRemoteHost rh_ = do when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding pure hostInfo handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () - handleHostError rhKeyVar action = do - action `catchChatError` \err -> do - logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err - sessions <- asks remoteHostSessions - session_ <- atomically $ readTVar rhKeyVar >>= (`TM.lookupDelete` sessions) - mapM_ (liftIO . cancelRemoteHost) session_ + handleHostError rhKeyVar action = action `catchChatError` \err -> do + logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err + readTVarIO rhKeyVar >>= cancelRemoteHostSession True True waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do (sessId, tls, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite @@ -185,7 +182,7 @@ startRemoteHost rh_ = do when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' toView $ CRNewRemoteHost rhi - disconnected <- toIO $ onDisconnected remoteHostId + disconnected <- toIO $ onDisconnected rhKey' httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient @@ -206,13 +203,10 @@ startRemoteHost rh_ = do Just rhi@RemoteHostInfo {remoteHostId} -> do withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' pure (rhi :: RemoteHostInfo) {sessionState = Just state} - onDisconnected :: ChatMonad m => RemoteHostId -> m () - onDisconnected remoteHostId = do + onDisconnected :: ChatMonad m => RHKey -> m () + onDisconnected rhKey = do logDebug "HTTP2 client disconnected" - chatModifyVar currentRemoteHost $ \cur -> if cur == Just remoteHostId then Nothing else cur -- only wipe the closing RH - sessions <- asks remoteHostSessions - void . atomically $ TM.lookupDelete (RHId remoteHostId) sessions - toView $ CRRemoteHostStopped remoteHostId + cancelRemoteHostSession True False rhKey pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () pollEvents rhId rhClient = do oq <- asks outputQ @@ -225,12 +219,23 @@ startRemoteHost rh_ = do closeRemoteHost :: ChatMonad m => RHKey -> m () closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey - chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH - join . withRemoteHostSession_ rhKey . maybe (Left $ ChatErrorRemoteHost rhKey RHEInactive) $ - \s -> Right (liftIO $ cancelRemoteHost s, Nothing) + cancelRemoteHostSession False True rhKey -cancelRemoteHost :: RemoteHostSession -> IO () -cancelRemoteHost = \case +cancelRemoteHostSession :: ChatMonad m => Bool -> Bool -> RHKey -> m () +cancelRemoteHostSession sendEvent stopHttp rhKey = handleAny (logError . tshow) $ do + chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH + sessions <- asks remoteHostSessions + session_ <- atomically $ TM.lookupDelete rhKey sessions + forM_ session_ $ \session -> do + liftIO $ cancelRemoteHost stopHttp session + when sendEvent $ toView $ CRRemoteHostStopped rhId_ + where + rhId_ = case rhKey of + RHNew -> Nothing + RHId rhId -> Just rhId + +cancelRemoteHost :: Bool -> RemoteHostSession -> IO () +cancelRemoteHost stopHttp = \case RHSessionStarting -> pure () RHSessionConnecting _inv rhs -> cancelPendingSession rhs RHSessionPendingConfirmation _sessCode tls rhs -> do @@ -241,9 +246,9 @@ cancelRemoteHost = \case closeConnection tls RHSessionConnected {rchClient, tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do uninterruptibleCancel pollAction - closeHTTP2Client httpClient - closeConnection tls - cancelHostClient rchClient + when stopHttp $ closeHTTP2Client httpClient `catchAny` (logError . tshow) + closeConnection tls `catchAny` (logError . tshow) + cancelHostClient rchClient `catchAny` (logError . tshow) where cancelPendingSession RHPendingSession {rchClient, rhsWaitSession} = do uninterruptibleCancel rhsWaitSession @@ -544,22 +549,23 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot monitor server = do res <- waitCatch server logInfo $ "HTTP2 server stopped: " <> tshow res - cancelActiveRemoteCtrl - toView CRRemoteCtrlStopped + cancelActiveRemoteCtrl True stopRemoteCtrl :: ChatMonad m => m () -stopRemoteCtrl = - join . withRemoteCtrlSession_ . maybe (Left $ ChatErrorRemoteCtrl RCEInactive) $ - \s -> Right (liftIO $ cancelRemoteCtrl s, Nothing) +stopRemoteCtrl = cancelActiveRemoteCtrl False handleCtrlError :: ChatMonad m => Text -> m a -> m a handleCtrlError name action = action `catchChatError` \e -> do logError $ name <> " remote ctrl error: " <> tshow e - cancelActiveRemoteCtrl + cancelActiveRemoteCtrl True throwError e -cancelActiveRemoteCtrl :: ChatMonad m => m () -cancelActiveRemoteCtrl = withRemoteCtrlSession_ (\s -> pure (s, Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) +cancelActiveRemoteCtrl :: ChatMonad m => Bool -> m () +cancelActiveRemoteCtrl sendEvent = handleAny (logError . tshow) $ do + session_ <- withRemoteCtrlSession_ (\s -> pure (s, Nothing)) + forM_ session_ $ \session -> do + liftIO $ cancelRemoteCtrl session + when sendEvent $ toView CRRemoteCtrlStopped cancelRemoteCtrl :: RemoteCtrlSession -> IO () cancelRemoteCtrl = \case diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 98a3edd478..a6843de601 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -297,7 +297,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe ] CRNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] - CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"] + CRRemoteHostStopped rhId_ -> + [ maybe "new remote host" (mappend "remote host " . sShow) rhId_ <> " stopped" + ] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index e3bef7f9e7..0d3dc74627 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -1,7 +1,6 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} module RemoteTests where @@ -40,7 +39,7 @@ remoteTests = describe "Remote" $ do it "sends messages" remoteMessageTest describe "remote files" $ do it "store/get/send/receive files" remoteStoreFileTest - it "should send files from CLI wihtout /store" remoteCLIFileTest + it "should send files from CLI without /store" remoteCLIFileTest it "switches remote hosts" switchRemoteHostTest it "indicates remote hosts" indicateRemoteHostTest @@ -439,24 +438,15 @@ stopDesktop :: HasCallStack => TestCC -> TestCC -> IO () stopDesktop mobile desktop = do logWarn "stopping via desktop" desktop ##> "/stop remote host 1" - -- desktop <## "ok" - concurrentlyN_ - [ do - desktop <## "remote host 1 stopped" - desktop <## "ok", - eventually 3 $ mobile <## "remote controller stopped" - ] + desktop <## "ok" + eventually 3 $ mobile <## "remote controller stopped" stopMobile :: HasCallStack => TestCC -> TestCC -> IO () stopMobile mobile desktop = do logWarn "stopping via mobile" mobile ##> "/stop remote ctrl" - concurrentlyN_ - [ do - mobile <## "remote controller stopped" - mobile <## "ok", - eventually 3 $ desktop <## "remote host 1 stopped" - ] + mobile <## "ok" + eventually 3 $ desktop <## "remote host 1 stopped" -- | Run action with extended timeout eventually :: Int -> IO a -> IO a From c31ae396176394f48287f5f1dfb0ce25b1f9d3f2 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 16 Nov 2023 16:56:39 +0200 Subject: [PATCH 196/294] remote: fix circular error handling (#3380) --- src/Simplex/Chat.hs | 4 ++-- src/Simplex/Chat/Remote.hs | 46 +++++++++++++++++++------------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ea16785e44..e2439f69d6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -375,8 +375,8 @@ restoreCalls = do stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do - readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost True) - atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl) + readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost False) + atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl False) disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 23d74404d5..331e3348a4 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -159,10 +159,10 @@ startRemoteHost rh_ = do handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () handleHostError rhKeyVar action = action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err - readTVarIO rhKeyVar >>= cancelRemoteHostSession True True + readTVarIO rhKeyVar >>= cancelRemoteHostSession True waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey rhKeyVar vars = do - (sessId, tls, vars') <- takeRCStep vars -- no timeout, waiting for user to scan invite + (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars let sessionCode = verificationCode sessId withRemoteHostSession rhKey $ \case RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') -- TODO check it's the same session? @@ -170,7 +170,7 @@ startRemoteHost rh_ = do -- display confirmation code, wait for mobile to confirm let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just $ RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} - (RCHostSession {sessionKeys}, rhHello, pairing') <- takeRCStep vars' -- no timeout, waiting for user to compare the code + (RCHostSession {sessionKeys}, rhHello, pairing') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars' hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello withRemoteHostSession rhKey $ \case @@ -206,7 +206,7 @@ startRemoteHost rh_ = do onDisconnected :: ChatMonad m => RHKey -> m () onDisconnected rhKey = do logDebug "HTTP2 client disconnected" - cancelRemoteHostSession True False rhKey + cancelRemoteHostSession True rhKey pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () pollEvents rhId rhClient = do oq <- asks outputQ @@ -219,23 +219,23 @@ startRemoteHost rh_ = do closeRemoteHost :: ChatMonad m => RHKey -> m () closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey - cancelRemoteHostSession False True rhKey + cancelRemoteHostSession False rhKey -cancelRemoteHostSession :: ChatMonad m => Bool -> Bool -> RHKey -> m () -cancelRemoteHostSession sendEvent stopHttp rhKey = handleAny (logError . tshow) $ do +cancelRemoteHostSession :: ChatMonad m => Bool -> RHKey -> m () +cancelRemoteHostSession handlingError rhKey = handleAny (logError . tshow) $ do chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH sessions <- asks remoteHostSessions - session_ <- atomically $ TM.lookupDelete rhKey sessions + session_ <- atomically $ TM.lookupDelete rhKey sessions -- XXX: when invoked from delayed error handler this can wipe the next session instead forM_ session_ $ \session -> do - liftIO $ cancelRemoteHost stopHttp session - when sendEvent $ toView $ CRRemoteHostStopped rhId_ + liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) + when handlingError $ toView $ CRRemoteHostStopped rhId_ where rhId_ = case rhKey of RHNew -> Nothing RHId rhId -> Just rhId cancelRemoteHost :: Bool -> RemoteHostSession -> IO () -cancelRemoteHost stopHttp = \case +cancelRemoteHost handlingError = \case RHSessionStarting -> pure () RHSessionConnecting _inv rhs -> cancelPendingSession rhs RHSessionPendingConfirmation _sessCode tls rhs -> do @@ -246,13 +246,13 @@ cancelRemoteHost stopHttp = \case closeConnection tls RHSessionConnected {rchClient, tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do uninterruptibleCancel pollAction - when stopHttp $ closeHTTP2Client httpClient `catchAny` (logError . tshow) - closeConnection tls `catchAny` (logError . tshow) cancelHostClient rchClient `catchAny` (logError . tshow) + closeConnection tls `catchAny` (logError . tshow) + unless handlingError $ closeHTTP2Client httpClient `catchAny` (logError . tshow) where cancelPendingSession RHPendingSession {rchClient, rhsWaitSession} = do - uninterruptibleCancel rhsWaitSession - cancelHostClient rchClient + unless handlingError $ uninterruptibleCancel rhsWaitSession `catchAny` (logError . tshow) + cancelHostClient rchClient `catchAny` (logError . tshow) -- | Generate a random 16-char filepath without / in it by using base64url encoding. randomStorePath :: IO FilePath @@ -561,24 +561,24 @@ handleCtrlError name action = action `catchChatError` \e -> do throwError e cancelActiveRemoteCtrl :: ChatMonad m => Bool -> m () -cancelActiveRemoteCtrl sendEvent = handleAny (logError . tshow) $ do +cancelActiveRemoteCtrl handlingError = handleAny (logError . tshow) $ do session_ <- withRemoteCtrlSession_ (\s -> pure (s, Nothing)) forM_ session_ $ \session -> do - liftIO $ cancelRemoteCtrl session - when sendEvent $ toView CRRemoteCtrlStopped + liftIO $ cancelRemoteCtrl handlingError session + when handlingError $ toView CRRemoteCtrlStopped -cancelRemoteCtrl :: RemoteCtrlSession -> IO () -cancelRemoteCtrl = \case +cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () +cancelRemoteCtrl handlingError = \case RCSessionStarting -> pure () RCSessionConnecting {rcsClient, rcsWaitSession} -> do - uninterruptibleCancel rcsWaitSession + unless handlingError $ uninterruptibleCancel rcsWaitSession cancelCtrlClient rcsClient RCSessionPendingConfirmation {rcsClient, tls, rcsWaitSession} -> do - uninterruptibleCancel rcsWaitSession + unless handlingError $ uninterruptibleCancel rcsWaitSession cancelCtrlClient rcsClient closeConnection tls RCSessionConnected {rcsClient, tls, http2Server} -> do - uninterruptibleCancel http2Server + unless handlingError $ uninterruptibleCancel http2Server cancelCtrlClient rcsClient closeConnection tls From 0322b9708b9675e67078c4fd9e1070eb939ceafa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Nov 2023 16:53:44 +0000 Subject: [PATCH 197/294] desktop, ios: remote desktop/mobile connection (#3223) * ui: remote desktop/mobile connection (WIP) * add startRemoteCtrl and capability (does not work) * re-add view * update core library * iOS connects to CLI * ios: mobile ui * multiplatform types * update lib * iOS and desktop connected * fix controllers list on mobile * remove iOS 16 paste button * update device name * connect existing device * proposed model * missing function names in exports * unused * remote host picker * update type * update lib, keep iOS session alive * better UI * update network statuses on switching remote/local hosts * changes * ios: prevent dismissing sheet/back when session is connected * changes * ios: fix back button asking to disconnect when not connected * iOS: update type * picker and session code * multiplatform: update type * menu fix * ios: better ux * desktop: better ux * ios: options etc * UI * desktop: fix RemoteHostStopped event * ios: open Use from desktop via picker * desktop: "new mobile device" * ios: load remote controllers synchronously, update on connect, fix alerts * titles * changes * more changes to ui * more and more changes in ui * padding * ios: show desktop version, handle errors * fix stopped event * refresh hosts always * radical change * optimization * change * ios: stop in progress session when closing window --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 35 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 73 ++- .../Shared/Views/ChatList/ChatListView.swift | 10 +- .../Shared/Views/ChatList/UserPicker.swift | 14 +- .../RemoteAccess/ConnectDesktopView.swift | 434 ++++++++++++++++++ .../Views/UserSettings/SettingsView.swift | 19 +- apps/ios/SimpleX (iOS).entitlements | 2 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 52 ++- apps/ios/SimpleXChat/APITypes.swift | 106 +++-- .../src/commonMain/cpp/android/simplex-api.c | 9 + .../src/commonMain/cpp/desktop/simplex-api.c | 9 + .../kotlin/chat/simplex/common/App.kt | 11 +- .../chat/simplex/common/model/ChatModel.kt | 19 + .../chat/simplex/common/model/SimpleXAPI.kt | 320 +++++++++---- .../chat/simplex/common/platform/Core.kt | 1 + .../simplex/common/views/chat/ChatView.kt | 5 +- .../common/views/chat/item/ChatItemView.kt | 2 +- .../common/views/chatlist/ChatListView.kt | 49 +- .../common/views/chatlist/ShareListView.kt | 7 +- .../common/views/chatlist/UserPicker.kt | 255 ++++++++-- .../simplex/common/views/helpers/ModalView.kt | 3 +- .../common/views/remote/ConnectMobileView.kt | 359 +++++++++++++++ .../common/views/usersettings/SettingsView.kt | 4 + .../commonMain/resources/MR/base/strings.xml | 18 + .../resources/MR/images/ic_smartphone.svg | 1 + .../resources/MR/images/ic_smartphone_300.svg | 1 + .../resources/MR/images/ic_wifi.svg | 1 + .../resources/MR/images/ic_wifi_off.svg | 1 + flake.nix | 2 + libsimplex.dll.def | 1 + 30 files changed, 1569 insertions(+), 254 deletions(-) create mode 100644 apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index fe9032e7c2..90e4272b0d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -85,6 +85,8 @@ final class ChatModel: ObservableObject { @Published var activeCall: Call? @Published var callCommand: WCallCommand? @Published var showCallView = false + // remote desktop + @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing QR code @Published var connReqInv: String? // audio recording and playback @@ -110,6 +112,10 @@ final class ChatModel: ObservableObject { notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() } + var activeRemoteCtrl: Bool { + remoteCtrlSession?.active ?? false + } + func getUser(_ userId: Int64) -> User? { currentUser?.userId == userId ? currentUser @@ -762,3 +768,32 @@ final class GMember: ObservableObject, Identifiable { var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } } static let sampleData = GMember(GroupMember.sampleData) } + +struct RemoteCtrlSession { + var ctrlAppInfo: CtrlAppInfo + var appVersion: String + var sessionState: UIRemoteCtrlSessionState + + func updateState(_ state: UIRemoteCtrlSessionState) -> RemoteCtrlSession { + RemoteCtrlSession(ctrlAppInfo: ctrlAppInfo, appVersion: appVersion, sessionState: state) + } + + var active: Bool { + if case .connected = sessionState { true } else { false } + } + + var sessionCode: String? { + switch sessionState { + case let .pendingConfirmation(_, sessionCode): sessionCode + case let .connected(_, sessionCode): sessionCode + default: nil + } + } +} + +enum UIRemoteCtrlSessionState { + case starting + case connecting(remoteCtrl_: RemoteCtrlInfo?) + case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) + case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String) +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f47d391931..f1aba91263 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -905,30 +905,36 @@ func apiCancelFile(fileId: Int64) async -> AChatItem? { } } -func startRemoteCtrl() async throws { - try await sendCommandOkResp(.startRemoteCtrl) +func setLocalDeviceName(_ displayName: String) throws { + try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName)) } -func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> RemoteCtrlInfo { - let r = await chatSendCmd(.registerRemoteCtrl(remoteCtrlOOB: remoteCtrlOOB)) - if case let .remoteCtrlRegistered(rcInfo) = r { return rcInfo } +func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { + let r = await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) + if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } throw r } -func listRemoteCtrls() async throws -> [RemoteCtrlInfo] { - let r = await chatSendCmd(.listRemoteCtrls) +func findKnownRemoteCtrl() async throws { + try await sendCommandOkResp(.findKnownRemoteCtrl) +} + +func confirmRemoteCtrl(_ rcId: Int64) async throws { + try await sendCommandOkResp(.confirmRemoteCtrl(remoteCtrlId: rcId)) +} + +func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo { + let r = await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode)) + if case let .remoteCtrlConnected(rc) = r { return rc } + throw r +} + +func listRemoteCtrls() throws -> [RemoteCtrlInfo] { + let r = chatSendCmdSync(.listRemoteCtrls) if case let .remoteCtrlList(rcInfo) = r { return rcInfo } throw r } -func acceptRemoteCtrl(_ rcId: Int64) async throws { - try await sendCommandOkResp(.acceptRemoteCtrl(remoteCtrlId: rcId)) -} - -func rejectRemoteCtrl(_ rcId: Int64) async throws { - try await sendCommandOkResp(.rejectRemoteCtrl(remoteCtrlId: rcId)) -} - func stopRemoteCtrl() async throws { try await sendCommandOkResp(.stopRemoteCtrl) } @@ -1065,6 +1071,12 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } +private func sendCommandOkRespSync(_ cmd: ChatCommand) throws { + let r = chatSendCmdSync(cmd) + if case .cmdOk = r { return } + throw r +} + func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo { let userId = try currentUserId("apiNewGroup") let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) @@ -1702,6 +1714,24 @@ func processReceivedMsg(_ res: ChatResponse) async { await MainActor.run { m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats) } + case let .remoteCtrlFound(remoteCtrl): + // TODO multicast + logger.debug("\(String(describing: remoteCtrl))") + case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): + await MainActor.run { + let state = UIRemoteCtrlSessionState.pendingConfirmation(remoteCtrl_: remoteCtrl_, sessionCode: sessionCode) + m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state) + } + case let .remoteCtrlConnected(remoteCtrl): + // TODO currently it is returned in response to command, so it is redundant + await MainActor.run { + let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "") + m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state) + } + case .remoteCtrlStopped: + await MainActor.run { + switchToLocalSession() + } default: logger.debug("unsupported event: \(res.responseType)") } @@ -1715,6 +1745,19 @@ func processReceivedMsg(_ res: ChatResponse) async { } } +func switchToLocalSession() { + let m = ChatModel.shared + m.remoteCtrlSession = nil + do { + m.users = try listUsers() + try getUserChatData() + let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) } + m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses) + } catch let error { + logger.debug("error updating chat data: \(responseError(error))") + } +} + func active(_ user: any UserLike) -> Bool { user.userId == ChatModel.shared.currentUser?.id } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index a006f333f8..1d86733206 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -15,6 +15,7 @@ struct ChatListView: View { @State private var searchText = "" @State private var showAddChat = false @State private var userPickerVisible = false + @State private var showConnectDesktop = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false var body: some View { @@ -48,7 +49,14 @@ struct ChatListView: View { } } } - UserPicker(showSettings: $showSettings, userPickerVisible: $userPickerVisible) + UserPicker( + showSettings: $showSettings, + showConnectDesktop: $showConnectDesktop, + userPickerVisible: $userPickerVisible + ) + } + .sheet(isPresented: $showConnectDesktop) { + ConnectDesktopView() } } diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index bb88f5c382..741af6f08f 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -13,6 +13,7 @@ struct UserPicker: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @Binding var showSettings: Bool + @Binding var showConnectDesktop: Bool @Binding var userPickerVisible: Bool @State var scrollViewContentSize: CGSize = .zero @State var disableScrolling: Bool = true @@ -62,6 +63,13 @@ struct UserPicker: View { .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000)) .frame(maxHeight: scrollViewContentSize.height) + menuButton("Use from desktop", icon: "desktopcomputer") { + showConnectDesktop = true + withAnimation { + userPickerVisible.toggle() + } + } + Divider() menuButton("Settings", icon: "gearshape") { showSettings = true withAnimation { @@ -85,7 +93,7 @@ struct UserPicker: View { do { m.users = try listUsers() } catch let error { - logger.error("Error updating users \(responseError(error))") + logger.error("Error loading users \(responseError(error))") } } } @@ -144,7 +152,8 @@ struct UserPicker: View { .overlay(DetermineWidth()) Spacer() Image(systemName: icon) -// .frame(width: 24, alignment: .center) + .symbolRenderingMode(.monochrome) + .foregroundColor(.secondary) } .padding(.horizontal) .padding(.vertical, 22) @@ -170,6 +179,7 @@ struct UserPicker_Previews: PreviewProvider { m.users = [UserInfo.sampleData, UserInfo.sampleData] return UserPicker( showSettings: Binding.constant(false), + showConnectDesktop: Binding.constant(false), userPickerVisible: Binding.constant(true) ) .environmentObject(m) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift new file mode 100644 index 0000000000..0f6ef7be0d --- /dev/null +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -0,0 +1,434 @@ +// +// ConnectDesktopView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 13/10/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import CodeScanner + +struct ConnectDesktopView: View { + @EnvironmentObject var m: ChatModel + @Environment(\.dismiss) var dismiss: DismissAction + var viaSettings = false + @AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name + @AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false + @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = false + @AppStorage(DEFAULT_OFFER_REMOTE_MULTICAST) private var offerRemoteMulticast = true + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @State private var sessionAddress: String = "" + @State private var remoteCtrls: [RemoteCtrlInfo] = [] + @State private var alert: ConnectDesktopAlert? + + private enum ConnectDesktopAlert: Identifiable { + case unlinkDesktop(rc: RemoteCtrlInfo) + case disconnectDesktop(action: UserDisconnectAction) + case badInvitationError + case badVersionError(version: String?) + case desktopDisconnectedError + case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + + var id: String { + switch self { + case let .unlinkDesktop(rc): "unlinkDesktop \(rc.remoteCtrlId)" + case let .disconnectDesktop(action): "disconnectDecktop \(action)" + case .badInvitationError: "badInvitationError" + case let .badVersionError(v): "badVersionError \(v ?? "")" + case .desktopDisconnectedError: "desktopDisconnectedError" + case let .error(title, _): "error \(title)" + } + } + } + + private enum UserDisconnectAction: String { + case back + case dismiss // TODO dismiss settings after confirmation + } + + var body: some View { + if viaSettings { + viewBody + .modifier(BackButton(label: "Back") { + if m.activeRemoteCtrl { + alert = .disconnectDesktop(action: .back) + } else { + dismiss() + } + }) + } else { + NavigationView { + viewBody + } + } + } + + var viewBody: some View { + Group { + if let session = m.remoteCtrlSession { + switch session.sessionState { + case .starting: connectingDesktopView(session, nil) + case let .connecting(rc_): connectingDesktopView(session, rc_) + case let .pendingConfirmation(rc_, sessCode): + if confirmRemoteSessions || rc_ == nil { + verifySessionView(session, rc_, sessCode) + } else { + connectingDesktopView(session, rc_).onAppear { + verifyDesktopSessionCode(sessCode) + } + } + case let .connected(rc, _): activeSessionView(session, rc) + } + } else { + connectDesktopView() + } + } + .onAppear { + setDeviceName(deviceName) + updateRemoteCtrls() + } + .onDisappear { + if m.remoteCtrlSession != nil { + disconnectDesktop() + } + } + .onChange(of: deviceName) { + setDeviceName($0) + } + .onChange(of: m.activeRemoteCtrl) { + UIApplication.shared.isIdleTimerDisabled = $0 + } + .alert(item: $alert) { a in + switch a { + case let .unlinkDesktop(rc): + Alert( + title: Text("Unlink desktop?"), + primaryButton: .destructive(Text("Unlink")) { + unlinkDesktop(rc) + }, + secondaryButton: .cancel() + ) + case let .disconnectDesktop(action): + Alert( + title: Text("Disconnect desktop?"), + primaryButton: .destructive(Text("Disconnect")) { + disconnectDesktop(action) + }, + secondaryButton: .cancel() + ) + case .badInvitationError: + Alert(title: Text("Bad desktop address")) + case let .badVersionError(v): + Alert( + title: Text("Incompatible version"), + message: Text("Desktop app version \(v ?? "") is not compatible with this app.") + ) + case .desktopDisconnectedError: + Alert(title: Text("Connection terminated")) + case let .error(title, error): + Alert(title: Text(title), message: Text(error)) + } + } + .interactiveDismissDisabled(m.activeRemoteCtrl) + } + + private func connectDesktopView() -> some View { + List { + Section("This device name") { + devicesView() + } + scanDesctopAddressView() + if developerTools { + desktopAddressView() + } + } + .navigationTitle("Connect to desktop") + } + + private func connectingDesktopView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> some View { + List { + Section("Connecting to desktop") { + ctrlDeviceNameText(session, rc) + ctrlDeviceVersionText(session) + } + + if let sessCode = session.sessionCode { + Section("Session code") { + sessionCodeText(sessCode) + } + } + + Section { + disconnectButton() + } + } + .navigationTitle("Connecting to desktop") + } + + private func verifySessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?, _ sessCode: String) -> some View { + List { + Section("Connected to desktop") { + ctrlDeviceNameText(session, rc) + ctrlDeviceVersionText(session) + } + + Section("Verify code with desktop") { + sessionCodeText(sessCode) + Button { + verifyDesktopSessionCode(sessCode) + } label: { + Label("Confirm", systemImage: "checkmark") + } + } + + Section { + disconnectButton() + } + } + .navigationTitle("Verify connection") + } + + private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text { + var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo.deviceName) + if (rc == nil) { + t = t + Text(" ") + Text("(new)").italic() + } + return t + } + + private func ctrlDeviceVersionText(_ session: RemoteCtrlSession) -> Text { + let v = session.ctrlAppInfo.appVersionRange.maxVersion + var t = Text("v\(v)") + if v != session.appVersion { + t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic() + } + return t + } + + private func activeSessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo) -> some View { + List { + Section("Connected desktop") { + Text(rc.deviceViewName) + ctrlDeviceVersionText(session) + } + + if let sessCode = session.sessionCode { + Section("Session code") { + sessionCodeText(sessCode) + } + } + + Section { + disconnectButton() + } footer: { + // This is specific to iOS + Text("Keep the app open to use it from desktop") + } + } + .navigationTitle("Connected to desktop") + } + + private func sessionCodeText(_ code: String) -> some View { + Text(code.prefix(23)) + } + + private func devicesView() -> some View { + Group { + TextField("Enter this device name…", text: $deviceName) + if !remoteCtrls.isEmpty { + NavigationLink { + linkedDesktopsView() + } label: { + Text("Linked desktops") + } + } + } + } + + private func scanDesctopAddressView() -> some View { + Section("Scan QR code from desktop") { + CodeScannerView(codeTypes: [.qr], completion: processDesktopQRCode) + .aspectRatio(1, contentMode: .fit) + .cornerRadius(12) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .padding(.horizontal) + } + } + + private func desktopAddressView() -> some View { + Section("Desktop address") { + if sessionAddress.isEmpty { + Button { + sessionAddress = UIPasteboard.general.string ?? "" + } label: { + Label("Paste desktop address", systemImage: "doc.plaintext") + } + .disabled(!UIPasteboard.general.hasStrings) + } else { + HStack { + Text(sessionAddress).lineLimit(1) + Spacer() + Image(systemName: "multiply.circle.fill") + .foregroundColor(.secondary) + .onTapGesture { sessionAddress = "" } + } + } + Button { + connectDesktopAddress(sessionAddress) + } label: { + Label("Connect to desktop", systemImage: "rectangle.connected.to.line.below") + } + .disabled(sessionAddress.isEmpty) + } + } + + private func linkedDesktopsView() -> some View { + List { + Section("Desktop devices") { + ForEach(remoteCtrls, id: \.remoteCtrlId) { rc in + remoteCtrlView(rc) + } + .onDelete { indexSet in + if let i = indexSet.first, i < remoteCtrls.count { + alert = .unlinkDesktop(rc: remoteCtrls[i]) + } + } + } + + Section("Linked desktop options") { + Toggle("Verify connections", isOn: $confirmRemoteSessions) + Toggle("Discover on network", isOn: $connectRemoteViaMulticast).disabled(true) + } + } + .navigationTitle("Linked desktops") + } + + private func remoteCtrlView(_ rc: RemoteCtrlInfo) -> some View { + Text(rc.deviceViewName) + } + + + private func setDeviceName(_ name: String) { + do { + try setLocalDeviceName(deviceName) + } catch let e { + errorAlert(e) + } + } + + private func updateRemoteCtrls() { + do { + remoteCtrls = try listRemoteCtrls() + } catch let e { + errorAlert(e) + } + } + + private func processDesktopQRCode(_ resp: Result) { + switch resp { + case let .success(r): connectDesktopAddress(r.string) + case let .failure(e): errorAlert(e) + } + } + + private func connectDesktopAddress(_ addr: String) { + Task { + do { + let (rc_, ctrlAppInfo, v) = try await connectRemoteCtrl(desktopAddress: addr) + await MainActor.run { + sessionAddress = "" + m.remoteCtrlSession = RemoteCtrlSession( + ctrlAppInfo: ctrlAppInfo, + appVersion: v, + sessionState: .connecting(remoteCtrl_: rc_) + ) + } + } catch let e { + await MainActor.run { + switch e as? ChatResponse { + case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError + case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError + case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v) + case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil) + case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError + default: errorAlert(e) + } + } + } + } + } + + private func verifyDesktopSessionCode(_ sessCode: String) { + Task { + do { + let rc = try await verifyRemoteCtrlSession(sessCode) + await MainActor.run { + m.remoteCtrlSession = m.remoteCtrlSession?.updateState(.connected(remoteCtrl: rc, sessionCode: sessCode)) + } + await MainActor.run { + updateRemoteCtrls() + } + } catch let error { + await MainActor.run { + errorAlert(error) + } + } + } + } + + private func disconnectButton() -> some View { + Button { + disconnectDesktop() + } label: { + Label("Disconnect", systemImage: "multiply") + } + } + + private func disconnectDesktop(_ action: UserDisconnectAction? = nil) { + Task { + do { + try await stopRemoteCtrl() + await MainActor.run { + switchToLocalSession() + switch action { + case .back: dismiss() + case .dismiss: dismiss() + case .none: () + } + } + } catch let e { + await MainActor.run { + errorAlert(e) + } + } + } + } + + private func unlinkDesktop(_ rc: RemoteCtrlInfo) { + Task { + do { + try await deleteRemoteCtrl(rc.remoteCtrlId) + await MainActor.run { + remoteCtrls.removeAll(where: { $0.remoteCtrlId == rc.remoteCtrlId }) + } + } catch let e { + await MainActor.run { + errorAlert(e) + } + } + } + } + + private func errorAlert(_ error: Error) { + let a = getErrorAlert(error, "Error") + alert = .error(title: a.title, error: a.message) + } +} + +#Preview { + ConnectDesktopView() +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 1cc859f49e..423786eb66 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -53,6 +53,10 @@ let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime" let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites" +let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" +let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions" +let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" +let DEFAULT_OFFER_REMOTE_MULTICAST = "offerRemoteMulticast" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -85,7 +89,10 @@ let appDefaults: [String: Any] = [ DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue, DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, - DEFAULT_SHOW_UNREAD_AND_FAVORITES: false + DEFAULT_SHOW_UNREAD_AND_FAVORITES: false, + DEFAULT_CONFIRM_REMOTE_SESSIONS: false, + DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: false, + DEFAULT_OFFER_REMOTE_MULTICAST: true ] enum SimpleXLinkMode: String, Identifiable { @@ -178,6 +185,12 @@ struct SettingsView: View { } label: { settingsRow("switch.2") { Text("Chat preferences") } } + + NavigationLink { + ConnectDesktopView(viaSettings: true) + } label: { + settingsRow("desktopcomputer") { Text("Use from desktop") } + } } .disabled(chatModel.chatRunning != true) @@ -362,7 +375,9 @@ struct SettingsView: View { func settingsRow(_ icon: String, color: Color = .secondary, content: @escaping () -> Content) -> some View { ZStack(alignment: .leading) { - Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(color) + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center) + .symbolRenderingMode(.monochrome) + .foregroundColor(color) content().padding(.leading, indent) } } diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 51672d6290..80e4adf2c2 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -18,5 +18,7 @@ $(AppIdentifierPrefix)chat.simplex.app + com.apple.developer.networking.multicast + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e675772e7d..62db4e43ec 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C36027227F47AD5009F19D9 /* AppDelegate.swift */; }; 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; + 5C3CCFCC2AE6BD3100C3F0C3 /* ConnectDesktopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */; }; 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; @@ -117,6 +118,11 @@ 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; + 5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A282B04FE2D00A71D61 /* libgmp.a */; }; + 5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */; }; + 5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */; }; + 5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */; }; + 5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -142,11 +148,6 @@ 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; - 5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF722AFF8D4D007893ED /* libffi.a */; }; - 5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */; }; - 5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */; }; - 5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF752AFF8D4E007893ED /* libgmp.a */; }; - 5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -282,6 +283,7 @@ 5C36027227F47AD5009F19D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; + 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectDesktopView.swift; sourceTree = ""; }; 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrityErrorItemView.swift; sourceTree = ""; }; 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; @@ -397,6 +399,11 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; + 5CDA5A282B04FE2D00A71D61 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a"; sourceTree = ""; }; + 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a"; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -423,11 +430,6 @@ 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; - 5CF4DF722AFF8D4D007893ED /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a"; sourceTree = ""; }; - 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CF4DF752AFF8D4E007893ED /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a"; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -505,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */, - 5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */, + 5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */, + 5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */, + 5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */, - 5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */, - 5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */, + 5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -544,6 +546,7 @@ 5CB924DD27A8622200ACCCDD /* NewChat */, 5CFA59C22860B04D00863A68 /* Database */, 5CB634AB29E46CDB0066AD6B /* LocalAuth */, + 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); @@ -572,11 +575,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CF4DF722AFF8D4D007893ED /* libffi.a */, - 5CF4DF752AFF8D4E007893ED /* libgmp.a */, - 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */, - 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */, - 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */, + 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */, + 5CDA5A282B04FE2D00A71D61 /* libgmp.a */, + 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */, + 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */, + 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */, ); path = Libraries; sourceTree = ""; @@ -684,6 +687,14 @@ path = "Tests iOS"; sourceTree = ""; }; + 5CA8D01B2AD9B076001FD661 /* RemoteAccess */ = { + isa = PBXGroup; + children = ( + 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */, + ); + path = RemoteAccess; + sourceTree = ""; + }; 5CB0BA8C282711BC00B3292C /* Onboarding */ = { isa = PBXGroup; children = ( @@ -1170,6 +1181,7 @@ 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */, 5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */, 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, + 5C3CCFCC2AE6BD3100C3F0C3 /* ConnectDesktopView.swift in Sources */, 5C9C2DA92899DA6F00CC63B1 /* NetworkAndServers.swift in Sources */, 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b893d00453..e7409a072b 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -120,14 +120,16 @@ public enum ChatCommand { case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?) case setFileToReceive(fileId: Int64, encrypted: Bool?) case cancelFile(fileId: Int64) + // remote desktop commands case setLocalDeviceName(displayName: String) - case startRemoteCtrl - case registerRemoteCtrl(remoteCtrlOOB: RemoteCtrlOOB) + case connectRemoteCtrl(xrcpInvitation: String) + case findKnownRemoteCtrl + case confirmRemoteCtrl(remoteCtrlId: Int64) + case verifyRemoteCtrlSession(sessionCode: String) case listRemoteCtrls - case acceptRemoteCtrl(remoteCtrlId: Int64) - case rejectRemoteCtrl(remoteCtrlId: Int64) case stopRemoteCtrl case deleteRemoteCtrl(remoteCtrlId: Int64) + // misc case showVersion case string(String) @@ -269,10 +271,10 @@ public enum ChatCommand { case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))" case let .cancelFile(fileId): return "/fcancel \(fileId)" case let .setLocalDeviceName(displayName): return "/set device name \(displayName)" - case .startRemoteCtrl: return "/start remote ctrl" - case let .registerRemoteCtrl(oob): return "/register remote ctrl \(oob.caFingerprint)" - case let .acceptRemoteCtrl(rcId): return "/accept remote ctrl \(rcId)" - case let .rejectRemoteCtrl(rcId): return "/reject remote ctrl \(rcId)" + case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)" + case .findKnownRemoteCtrl: return "/find remote ctrl" + case let .confirmRemoteCtrl(rcId): return "/confirm remote ctrl \(rcId)" + case let .verifyRemoteCtrlSession(sessCode): return "/verify remote ctrl \(sessCode)" case .listRemoteCtrls: return "/list remote ctrls" case .stopRemoteCtrl: return "/stop remote ctrl" case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" @@ -392,11 +394,11 @@ public enum ChatCommand { case .setFileToReceive: return "setFileToReceive" case .cancelFile: return "cancelFile" case .setLocalDeviceName: return "setLocalDeviceName" - case .startRemoteCtrl: return "startRemoteCtrl" - case .registerRemoteCtrl: return "registerRemoteCtrl" + case .connectRemoteCtrl: return "connectRemoteCtrl" + case .findKnownRemoteCtrl: return "findKnownRemoteCtrl" + case .confirmRemoteCtrl: return "confirmRemoteCtrl" + case .verifyRemoteCtrlSession: return "verifyRemoteCtrlSession" case .listRemoteCtrls: return "listRemoteCtrls" - case .acceptRemoteCtrl: return "acceptRemoteCtrl" - case .rejectRemoteCtrl: return "rejectRemoteCtrl" case .stopRemoteCtrl: return "stopRemoteCtrl" case .deleteRemoteCtrl: return "deleteRemoteCtrl" case .showVersion: return "showVersion" @@ -605,13 +607,14 @@ public enum ChatResponse: Decodable, Error { case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case newContactConnection(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) + // remote desktop responses/events case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) - case remoteCtrlRegistered(remoteCtrl: RemoteCtrlInfo) - case remoteCtrlAnnounce(fingerprint: String) case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo) - case remoteCtrlConnecting(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) + case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) case remoteCtrlStopped + // misc case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) case chatCmdError(user_: UserRef?, chatError: ChatError) @@ -752,10 +755,9 @@ public enum ChatResponse: Decodable, Error { case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" case .remoteCtrlList: return "remoteCtrlList" - case .remoteCtrlRegistered: return "remoteCtrlRegistered" - case .remoteCtrlAnnounce: return "remoteCtrlAnnounce" case .remoteCtrlFound: return "remoteCtrlFound" case .remoteCtrlConnecting: return "remoteCtrlConnecting" + case .remoteCtrlSessionCode: return "remoteCtrlSessionCode" case .remoteCtrlConnected: return "remoteCtrlConnected" case .remoteCtrlStopped: return "remoteCtrlStopped" case .versionInfo: return "versionInfo" @@ -901,10 +903,9 @@ public enum ChatResponse: Decodable, Error { case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlRegistered(remoteCtrl): return String(describing: remoteCtrl) - case let .remoteCtrlAnnounce(fingerprint): return "fingerprint: \(fingerprint)" case let .remoteCtrlFound(remoteCtrl): return String(describing: remoteCtrl) - case let .remoteCtrlConnecting(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" + case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) case .remoteCtrlStopped: return noDetails case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))" @@ -1533,21 +1534,31 @@ public enum NotificationPreviewMode: String, SelectableItem { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } -public struct RemoteCtrlOOB { - public var caFingerprint: String -} - public struct RemoteCtrlInfo: Decodable { public var remoteCtrlId: Int64 - public var displayName: String - public var sessionActive: Bool + public var ctrlDeviceName: String + public var sessionState: RemoteCtrlSessionState? + + public var deviceViewName: String { + ctrlDeviceName == "" ? "\(remoteCtrlId)" : ctrlDeviceName + } } -public struct RemoteCtrl: Decodable { - var remoteCtrlId: Int64 - var displayName: String - var fingerprint: String - var accepted: Bool? +public enum RemoteCtrlSessionState: Decodable { + case starting + case connecting + case pendingConfirmation(sessionCode: String) + case connected(sessionCode: String) +} + +public struct CtrlAppInfo: Decodable { + public var appVersionRange: AppVersionRange + public var deviceName: String +} + +public struct AppVersionRange: Decodable { + public var minVersion: String + public var maxVersion: String } public struct CoreVersionInfo: Decodable { @@ -1737,6 +1748,7 @@ public enum AgentErrorType: Decodable { case SMP(smpErr: ProtocolErrorType) case NTF(ntfErr: ProtocolErrorType) case XFTP(xftpErr: XFTPErrorType) + case RCP(rcpErr: RCErrorType) case BROKER(brokerAddress: String, brokerErr: BrokerErrorType) case AGENT(agentErr: SMPAgentError) case INTERNAL(internalErr: String) @@ -1794,6 +1806,22 @@ public enum XFTPErrorType: Decodable { case INTERNAL } +public enum RCErrorType: Decodable { + case `internal`(internalErr: String) + case identity + case noLocalAddress + case tlsStartFailed + case exception(exception: String) + case ctrlAuth + case ctrlNotFound + case ctrlError(ctrlErr: String) + case version + case encrypt + case decrypt + case blockSize + case syntax(syntaxErr: String) +} + public enum ProtocolCommandError: Decodable { case UNKNOWN case SYNTAX @@ -1831,12 +1859,12 @@ public enum ArchiveError: Decodable { } public enum RemoteCtrlError: Decodable { - case inactive - case busy - case timeout - case disconnected(remoteCtrlId: Int64, reason: String) - case connectionLost(remoteCtrlId: Int64, reason: String) - case certificateExpired(remoteCtrlId: Int64) - case certificateUntrusted(remoteCtrlId: Int64) - case badFingerprint + case inactive + case badState + case busy + case timeout + case disconnected(remoteCtrlId: Int64, reason: String) + case badInvitation + case badVersion(appVersion: String) +// case protocolError(protocolError: RemoteProtocolError) } diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 351ed93c97..b729e3b7f5 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -41,6 +41,7 @@ typedef long* chat_ctrl; extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); +extern char *chat_send_remote_cmd(chat_ctrl ctrl, const int rhId, const char *cmd); extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); @@ -86,6 +87,14 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, __unused jclas return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jint rhId, jstring msg) { + const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_send_remote_cmd((void*)controller, rhId, _msg)); + (*env)->ReleaseStringUTFChars(env, msg, _msg); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) { return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller)); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index f36c86c366..1b0d11a2b7 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -16,6 +16,7 @@ typedef long* chat_ctrl; extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); +extern char *chat_send_remote_cmd(chat_ctrl ctrl, const int rhId, const char *cmd); extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); @@ -98,6 +99,14 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, jclass clazz, return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmd(JNIEnv *env, jclass clazz, jlong controller, jint rhId, jstring msg) { + const char *_msg = encode_to_utf8_chars(env, msg); + jstring res = decode_to_utf8_string(env, chat_send_remote_cmd((void*)controller, rhId, _msg)); + (*env)->ReleaseStringUTFChars(env, msg, _msg); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, jclass clazz, jlong controller) { return decode_to_utf8_string(env, chat_recv_msg((void*)controller)); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 82201cce04..41deee7a55 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.* data class SettingsViewState( val userPickerState: MutableStateFlow, val scaffoldState: ScaffoldState, - val switchingUsers: MutableState + val switchingUsersAndHosts: MutableState ) @Composable @@ -121,8 +121,8 @@ fun MainScreen() { showAdvertiseLAAlert = true val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } val scaffoldState = rememberScaffoldState() - val switchingUsers = rememberSaveable { mutableStateOf(false) } - val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsers) } + val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) } + val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) } if (appPlatform.isAndroid) { AndroidScreen(settingsState) } else { @@ -298,7 +298,7 @@ fun DesktopScreen(settingsState: SettingsViewState) { EndPartOfScreen() } } - val (userPickerState, scaffoldState, switchingUsers ) = settingsState + val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState val scope = rememberCoroutineScope() if (scaffoldState.drawerState.isOpen) { Box( @@ -312,8 +312,9 @@ fun DesktopScreen(settingsState: SettingsViewState) { ) } VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) - UserPicker(chatModel, userPickerState, switchingUsers) { + UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + userPickerState.value = AnimatedViewState.GONE } ModalManager.fullscreen.showInView() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 91b4a8d8f6..2de944f592 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -106,6 +106,11 @@ object ChatModel { var updatingChatsMutex: Mutex = Mutex() + // remote controller + val remoteHosts = mutableStateListOf() + val currentRemoteHost = mutableStateOf(null) + val newRemoteHostPairing = mutableStateOf?>(null) + fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -2841,3 +2846,17 @@ enum class NotificationPreviewMode { val default: NotificationPreviewMode = MESSAGE } } + +data class RemoteCtrlSession( + val ctrlAppInfo: CtrlAppInfo, + val appVersion: String, + val sessionState: RemoteCtrlSessionState +) + +@Serializable +sealed class RemoteCtrlSessionState { + @Serializable @SerialName("starting") object Starting: RemoteCtrlSessionState() + @Serializable @SerialName("connecting") object Connecting: RemoteCtrlSessionState() + @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val sessionCode: String): RemoteCtrlSessionState() + @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState() +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7896d2d6e3..98c48dbfb4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -345,11 +345,6 @@ object ChatController { val users = listUsers() chatModel.users.clear() chatModel.users.addAll(users) - val remoteHosts = listRemoteHosts() - if (remoteHosts != null) { - chatModel.remoteHosts.clear() - chatModel.remoteHosts.addAll(remoteHosts) - } if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true @@ -357,6 +352,7 @@ object ChatController { appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true startReceiver() + setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!) Log.d(TAG, "startChat: started") } else { updatingChatsMutex.withLock { @@ -429,7 +425,8 @@ object ChatController { val c = cmd.cmdString chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") - val json = chatSendCmd(ctrl, c) + val rhId = chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 + val json = if (rhId == -1) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId, c) val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") if (r.resp is CR.Response || r.resp is CR.Invalid) { @@ -1174,10 +1171,10 @@ object ChatController { } } - suspend fun cancelFile(user: User, fileId: Long) { + suspend fun cancelFile(rhId: Long?, user: User, fileId: Long) { val chatItem = apiCancelFile(fileId) if (chatItem != null) { - chatItemSimpleUpdate(user, chatItem) + chatItemSimpleUpdate(rhId, user, chatItem) cleanupFile(chatItem) } } @@ -1371,46 +1368,77 @@ object ChatController { suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(CC.SetLocalDeviceName(displayName)) - suspend fun createRemoteHost(): RemoteHostInfo? { - val r = sendCmd(CC.CreateRemoteHost()) - if (r is CR.RemoteHostCreated) return r.remoteHost - apiErrorAlert("createRemoteHost", generalGetString(MR.strings.error), r) - return null - } - suspend fun listRemoteHosts(): List? { val r = sendCmd(CC.ListRemoteHosts()) if (r is CR.RemoteHostList) return r.remoteHosts - apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error), r) + apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun startRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StartRemoteHost(rhId)) + suspend fun reloadRemoteHosts() { + val hosts = listRemoteHosts() ?: return + chatModel.remoteHosts.clear() + chatModel.remoteHosts.addAll(hosts) + } - suspend fun registerRemoteCtrl(oob: RemoteCtrlOOB): RemoteCtrlInfo? { - val r = sendCmd(CC.RegisterRemoteCtrl(oob)) - if (r is CR.RemoteCtrlRegistered) return r.remoteCtrl - apiErrorAlert("registerRemoteCtrl", generalGetString(MR.strings.error), r) + suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Pair? { + val r = sendCmd(CC.StartRemoteHost(rhId, multicast)) + if (r is CR.RemoteHostStarted) return r.remoteHost_ to r.invitation + apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null } + suspend fun switchRemoteHost (rhId: Long?): RemoteHostInfo? { + val r = sendCmd(CC.SwitchRemoteHost(rhId)) + if (r is CR.CurrentRemoteHost) return r.remoteHost_ + apiErrorAlert("switchRemoteHost", generalGetString(MR.strings.error_alert_title), r) + return null + } + + suspend fun stopRemoteHost(rhId: Long?): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId)) + + fun stopRemoteHostAndReloadHosts(h: RemoteHostInfo, switchToLocal: Boolean) { + withBGApi { + stopRemoteHost(h.remoteHostId) + if (switchToLocal) { + switchUIRemoteHost(null) + } else { + reloadRemoteHosts() + } + } + } + + suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId)) + + suspend fun storeRemoteFile(rhId: Long, storeEncrypted: Boolean?, localPath: String): CryptoFile? { + val r = sendCmd(CC.StoreRemoteFile(rhId, storeEncrypted, localPath)) + if (r is CR.RemoteFileStored) return r.remoteFileSource + apiErrorAlert("storeRemoteFile", generalGetString(MR.strings.error_alert_title), r) + return null + } + + suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCommandOkResp(CC.GetRemoteFile(rhId, file)) + + suspend fun connectRemoteCtrl(invitation: String): SomeRemoteCtrl? { + val r = sendCmd(CC.ConnectRemoteCtrl(invitation)) + if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) + apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + return null + } + + suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(CC.FindKnownRemoteCtrl()) + + suspend fun confirmRemoteCtrl(rhId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rhId)) + + suspend fun verifyRemoteCtrlSession(sessionCode: String): Boolean = sendCommandOkResp(CC.VerifyRemoteCtrlSession(sessionCode)) + suspend fun listRemoteCtrls(): List? { val r = sendCmd(CC.ListRemoteCtrls()) if (r is CR.RemoteCtrlList) return r.remoteCtrls - apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error), r) + apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun stopRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId)) - - suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId)) - - suspend fun startRemoteCtrl(): Boolean = sendCommandOkResp(CC.StartRemoteCtrl()) - - suspend fun acceptRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.AcceptRemoteCtrl(rcId)) - - suspend fun rejectRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.RejectRemoteCtrl(rcId)) - suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(CC.StopRemoteCtrl()) suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteCtrl(rcId)) @@ -1465,6 +1493,8 @@ object ChatController { private suspend fun processReceivedMsg(apiResp: APIResponse) { lastMsgReceivedTimestamp = System.currentTimeMillis() val r = apiResp.resp + val rhId = apiResp.remoteHostId + fun active(user: UserLike): Boolean = activeUser(rhId, user) chatModel.addTerminalItem(TerminalItem.resp(r)) when (r) { is CR.NewContactConnection -> { @@ -1577,7 +1607,7 @@ object ChatController { ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { - withApi { receiveFile(r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) } + withApi { receiveFile(rhId, r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) } } if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) @@ -1591,7 +1621,7 @@ object ChatController { } } is CR.ChatItemUpdated -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.ChatItemReaction -> { if (active(r.user)) { chatModel.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) @@ -1703,37 +1733,37 @@ object ChatController { chatModel.updateContact(r.contact) } is CR.RcvFileStart -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.RcvFileComplete -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.RcvFileSndCancelled -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.RcvFileProgressXFTP -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.RcvFileError -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.SndFileStart -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.SndFileComplete -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupDirectFile(r.chatItem) } is CR.SndFileRcvCancelled -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupDirectFile(r.chatItem) } is CR.SndFileProgressXFTP -> - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.SndFileCompleteXFTP -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.SndFileError -> { - chatItemSimpleUpdate(r.user, r.chatItem) + chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.CallInvitation -> { @@ -1789,12 +1819,18 @@ object ChatController { chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats) is CR.GroupMemberRatchetSync -> chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + is CR.RemoteHostSessionCode -> { + chatModel.newRemoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) + } is CR.RemoteHostConnected -> { - // update - chatModel.connectingRemoteHost.value = r.remoteHost + // TODO needs to update it instead in sessions + chatModel.currentRemoteHost.value = r.remoteHost + switchUIRemoteHost(r.remoteHost.remoteHostId) } is CR.RemoteHostStopped -> { - // + chatModel.currentRemoteHost.value = null + chatModel.newRemoteHostPairing.value = null + switchUIRemoteHost(null) } else -> Log.d(TAG , "unsupported event: ${r.responseType}") @@ -1819,7 +1855,8 @@ object ChatController { } } - private fun active(user: UserLike): Boolean = user.userId == chatModel.currentUser.value?.userId + private fun activeUser(rhId: Long?, user: UserLike): Boolean = + rhId == chatModel.currentRemoteHost.value?.remoteHostId && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { val call = chatModel.activeCall.value @@ -1830,10 +1867,10 @@ object ChatController { } } - suspend fun receiveFile(user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { + suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto) if (chatItem != null) { - chatItemSimpleUpdate(user, chatItem) + chatItemSimpleUpdate(rhId, user, chatItem) } } @@ -1844,11 +1881,11 @@ object ChatController { } } - private suspend fun chatItemSimpleUpdate(user: UserLike, aChatItem: AChatItem) { + private suspend fun chatItemSimpleUpdate(rhId: Long?, user: UserLike, aChatItem: AChatItem) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem val notify = { ntfManager.notifyMessageReceived(user, cInfo, cItem) } - if (!active(user)) { + if (!activeUser(rhId, user)) { notify() } else if (chatModel.upsertChatItem(cInfo, cItem)) { notify() @@ -1876,6 +1913,25 @@ object ChatController { chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) } + suspend fun switchUIRemoteHost(rhId: Long?) { + chatModel.chatId.value = null + chatModel.currentRemoteHost.value = switchRemoteHost(rhId) + reloadRemoteHosts() + val user = apiGetActiveUser() + val users = listUsers() + chatModel.users.clear() + chatModel.users.addAll(users) + chatModel.currentUser.value = user + chatModel.userCreated.value = true + val statuses = apiGetNetworkStatuses() + if (statuses != null) { + chatModel.networkStatuses.clear() + val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() + chatModel.networkStatuses.putAll(ss) + } + getUserChatData() + } + fun getXFTPCfg(): XFTPFileConfig { return XFTPFileConfig(minFileSize = 0) } @@ -2059,19 +2115,23 @@ sealed class CC { class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val encrypt: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() + // Remote control class SetLocalDeviceName(val displayName: String): CC() - class CreateRemoteHost(): CC() class ListRemoteHosts(): CC() - class StartRemoteHost(val remoteHostId: Long): CC() - class StopRemoteHost(val remoteHostId: Long): CC() + class StartRemoteHost(val remoteHostId: Long?, val multicast: Boolean): CC() + class SwitchRemoteHost (val remoteHostId: Long?): CC() + class StopRemoteHost(val remoteHostKey: Long?): CC() class DeleteRemoteHost(val remoteHostId: Long): CC() - class StartRemoteCtrl(): CC() - class RegisterRemoteCtrl(val remoteCtrlOOB: RemoteCtrlOOB): CC() + class StoreRemoteFile(val remoteHostId: Long, val storeEncrypted: Boolean?, val localPath: String): CC() + class GetRemoteFile(val remoteHostId: Long, val file: RemoteFile): CC() + class ConnectRemoteCtrl(val xrcpInvitation: String): CC() + class FindKnownRemoteCtrl(): CC() + class ConfirmRemoteCtrl(val remoteCtrlId: Long): CC() + class VerifyRemoteCtrlSession(val sessionCode: String): CC() class ListRemoteCtrls(): CC() - class AcceptRemoteCtrl(val remoteCtrlId: Long): CC() - class RejectRemoteCtrl(val remoteCtrlId: Long): CC() class StopRemoteCtrl(): CC() class DeleteRemoteCtrl(val remoteCtrlId: Long): CC() + // misc class ShowVersion(): CC() val cmdString: String get() = when (this) { @@ -2192,15 +2252,20 @@ sealed class CC { (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" is SetLocalDeviceName -> "/set device name $displayName" - is CreateRemoteHost -> "/create remote host" is ListRemoteHosts -> "/list remote hosts" - is StartRemoteHost -> "/start remote host $remoteHostId" - is StopRemoteHost -> "/stop remote host $remoteHostId" + is StartRemoteHost -> "/start remote host " + if (remoteHostId == null) "new" else "$remoteHostId multicast=${onOff(multicast)}" + is SwitchRemoteHost -> "/switch remote host " + if (remoteHostId == null) "local" else "$remoteHostId" + is StopRemoteHost -> "/stop remote host " + if (remoteHostKey == null) "new" else "$remoteHostKey" is DeleteRemoteHost -> "/delete remote host $remoteHostId" - is StartRemoteCtrl -> "/start remote ctrl" - is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.fingerprint}" - is AcceptRemoteCtrl -> "/accept remote ctrl $remoteCtrlId" - is RejectRemoteCtrl -> "/reject remote ctrl $remoteCtrlId" + is StoreRemoteFile -> + "/store remote file $remoteHostId " + + (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)}") + + localPath + is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}" + is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation" + is FindKnownRemoteCtrl -> "/find remote ctrl" + is ConfirmRemoteCtrl -> "/confirm remote ctrl $remoteCtrlId" + is VerifyRemoteCtrlSession -> "/verify remote ctrl $sessionCode" is ListRemoteCtrls -> "/list remote ctrls" is StopRemoteCtrl -> "/stop remote ctrl" is DeleteRemoteCtrl -> "/delete remote ctrl $remoteCtrlId" @@ -2306,16 +2371,18 @@ sealed class CC { is ReceiveFile -> "receiveFile" is CancelFile -> "cancelFile" is SetLocalDeviceName -> "setLocalDeviceName" - is CreateRemoteHost -> "createRemoteHost" is ListRemoteHosts -> "listRemoteHosts" is StartRemoteHost -> "startRemoteHost" + is SwitchRemoteHost -> "switchRemoteHost" is StopRemoteHost -> "stopRemoteHost" is DeleteRemoteHost -> "deleteRemoteHost" - is StartRemoteCtrl -> "startRemoteCtrl" - is RegisterRemoteCtrl -> "registerRemoteCtrl" + is StoreRemoteFile -> "storeRemoteFile" + is GetRemoteFile -> "getRemoteFile" + is ConnectRemoteCtrl -> "connectRemoteCtrl" + is FindKnownRemoteCtrl -> "FindKnownRemoteCtrl" + is ConfirmRemoteCtrl -> "confirmRemoteCtrl" + is VerifyRemoteCtrlSession -> "verifyRemoteCtrlSession" is ListRemoteCtrls -> "listRemoteCtrls" - is AcceptRemoteCtrl -> "acceptRemoteCtrl" - is RejectRemoteCtrl -> "rejectRemoteCtrl" is StopRemoteCtrl -> "stopRemoteCtrl" is DeleteRemoteCtrl -> "deleteRemoteCtrl" is ShowVersion -> "showVersion" @@ -3388,27 +3455,34 @@ data class RemoteCtrl ( val accepted: Boolean? ) -@Serializable -data class RemoteCtrlOOB ( - val fingerprint: String, - val displayName: String -) - @Serializable data class RemoteCtrlInfo ( val remoteCtrlId: Long, - val displayName: String, - val sessionActive: Boolean + val ctrlDeviceName: String, + val sessionState: RemoteCtrlSessionState? ) @Serializable -data class RemoteHostInfo ( +data class RemoteHostInfo( val remoteHostId: Long, + val hostDeviceName: String, val storePath: String, - val displayName: String, - val remoteCtrlOOB: RemoteCtrlOOB, - val sessionActive: Boolean -) + val sessionState: RemoteHostSessionState? +) { + val activeHost: Boolean + @Composable get() = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId + + fun activeHost(): Boolean = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId +} + +@Serializable +sealed class RemoteHostSessionState { + @Serializable @SerialName("starting") object Starting: RemoteHostSessionState() + @Serializable @SerialName("connecting") class Connecting(val invitation: String): RemoteHostSessionState() + @Serializable @SerialName("pendingConfirmation") class PendingConfirmation(val sessionCode: String): RemoteHostSessionState() + @Serializable @SerialName("confirmed") data class Confirmed(val sessionCode: String): RemoteHostSessionState() + @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteHostSessionState() +} val json = Json { prettyPrint = true @@ -3621,16 +3695,19 @@ sealed class CR { @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR() // remote events (desktop) - @Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() + @Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR() + @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String): CR() + @Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR() + @Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR() - @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR() + @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?): CR() + @Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR() // remote events (mobile) @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() - @Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrl: RemoteCtrlInfo): CR() - @Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR() @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo): CR() - @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl: RemoteCtrlInfo): CR() + @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): CR() + @Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR() @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR() @Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR() @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List, val agentMigrations: List): CR() @@ -3767,15 +3844,18 @@ sealed class CR { is CallEnded -> "callEnded" is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" - is RemoteHostCreated -> "remoteHostCreated" is RemoteHostList -> "remoteHostList" + is CurrentRemoteHost -> "currentRemoteHost" + is RemoteHostStarted -> "remoteHostStarted" + is RemoteHostSessionCode -> "remoteHostSessionCode" + is NewRemoteHost -> "newRemoteHost" is RemoteHostConnected -> "remoteHostConnected" is RemoteHostStopped -> "remoteHostStopped" + is RemoteFileStored -> "remoteFileStored" is RemoteCtrlList -> "remoteCtrlList" - is RemoteCtrlRegistered -> "remoteCtrlRegistered" - is RemoteCtrlAnnounce -> "remoteCtrlAnnounce" is RemoteCtrlFound -> "remoteCtrlFound" is RemoteCtrlConnecting -> "remoteCtrlConnecting" + is RemoteCtrlSessionCode -> "remoteCtrlSessionCode" is RemoteCtrlConnected -> "remoteCtrlConnected" is RemoteCtrlStopped -> "remoteCtrlStopped" is VersionInfo -> "versionInfo" @@ -3912,15 +3992,29 @@ sealed class CR { is CallEnded -> withUser(user, "contact: ${contact.id}") is NewContactConnection -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) - is RemoteHostCreated -> json.encodeToString(remoteHost) + // remote events (mobile) is RemoteHostList -> json.encodeToString(remoteHosts) + is CurrentRemoteHost -> if (remoteHost_ == null) "local" else json.encodeToString(remoteHost_) + is RemoteHostStarted -> if (remoteHost_ == null) "new" else json.encodeToString(remoteHost_) + is RemoteHostSessionCode -> + "remote host: " + + (if (remoteHost_ == null) "new" else json.encodeToString(remoteHost_)) + + "\nsession code: $sessionCode" + is NewRemoteHost -> json.encodeToString(remoteHost) is RemoteHostConnected -> json.encodeToString(remoteHost) - is RemoteHostStopped -> "remote host ID: $remoteHostId" + is RemoteHostStopped -> "remote host ID: $remoteHostId_" + is RemoteFileStored -> "remote host ID: $remoteHostId\nremoteFileSource:\n" + json.encodeToString(remoteFileSource) is RemoteCtrlList -> json.encodeToString(remoteCtrls) - is RemoteCtrlRegistered -> json.encodeToString(remoteCtrl) - is RemoteCtrlAnnounce -> "fingerprint: $fingerprint" is RemoteCtrlFound -> json.encodeToString(remoteCtrl) - is RemoteCtrlConnecting -> json.encodeToString(remoteCtrl) + is RemoteCtrlConnecting -> + "remote ctrl: " + + (if (remoteCtrl_ == null) "null" else json.encodeToString(remoteCtrl_)) + + "\nctrlAppInfo:\n${json.encodeToString(ctrlAppInfo)}" + + "\nappVersion: $appVersion" + is RemoteCtrlSessionCode -> + "remote ctrl: " + + (if (remoteCtrl_ == null) "null" else json.encodeToString(remoteCtrl_)) + + "\nsessionCode: $sessionCode" is RemoteCtrlConnected -> json.encodeToString(remoteCtrl) is RemoteCtrlStopped -> noDetails() is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" + @@ -4102,6 +4196,26 @@ data class CoreVersionInfo( val simplexmqCommit: String ) +data class SomeRemoteCtrl( + val remoteCtrl_: RemoteCtrlInfo?, + val ctrlAppInfo: CtrlAppInfo, + val appVersion: String +) + +@Serializable +data class CtrlAppInfo(val appVersionRange: AppVersionRange, val deviceName: String) + +@Serializable +data class AppVersionRange(val minVersion: String, val maxVersion: String) + +@Serializable +data class RemoteFile( + val userId: Long, + val fileId: Long, + val sent: Boolean, + val fileSource: CryptoFile +) + @Serializable sealed class ChatError { val string: String get() = when (this) { @@ -4624,18 +4738,20 @@ sealed class ArchiveError { sealed class RemoteHostError { val string: String get() = when (this) { is Missing -> "missing" + is Inactive -> "inactive" is Busy -> "busy" - is Rejected -> "rejected" is Timeout -> "timeout" + is BadState -> "badState" + is BadVersion -> "badVersion" is Disconnected -> "disconnected" - is ConnectionLost -> "connectionLost" } @Serializable @SerialName("missing") object Missing: RemoteHostError() + @Serializable @SerialName("inactive") object Inactive: RemoteHostError() @Serializable @SerialName("busy") object Busy: RemoteHostError() - @Serializable @SerialName("rejected") object Rejected: RemoteHostError() @Serializable @SerialName("timeout") object Timeout: RemoteHostError() + @Serializable @SerialName("badState") object BadState: RemoteHostError() + @Serializable @SerialName("badVersion") object BadVersion: RemoteHostError() @Serializable @SerialName("disconnected") class Disconnected(val reason: String): RemoteHostError() - @Serializable @SerialName("connectionLost") class ConnectionLost(val reason: String): RemoteHostError() } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 2bed24b1f6..c32137ee61 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -15,6 +15,7 @@ external fun pipeStdOutToSocket(socketName: String) : Int typealias ChatCtrl = Long external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String +external fun chatSendRemoteCmd(ctrl: ChatCtrl, rhId: Int, msg: String): String external fun chatRecvMsg(ctrl: ChatCtrl): String external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String external fun chatParseMarkdown(str: String): String diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 42e43f7b7a..2d526c513a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -46,6 +46,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) } val searchText = rememberSaveable { mutableStateOf("") } val user = chatModel.currentUser.value + val rhId = remember { chatModel.currentRemoteHost }.value?.remoteHostId val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { mutableStateOf( @@ -284,10 +285,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } }, receiveFile = { fileId, encrypted -> - withApi { chatModel.controller.receiveFile(user, fileId, encrypted) } + withApi { chatModel.controller.receiveFile(rhId, user, fileId, encrypted) } }, cancelFile = { fileId -> - withApi { chatModel.controller.cancelFile(user, fileId) } + withApi { chatModel.controller.cancelFile(rhId, user, fileId) } }, joinGroup = { groupId, onComplete -> withApi { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index fdf361bf69..095723a18e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -590,7 +590,7 @@ private fun ShrinkItemAction(revealed: MutableState, showMenu: MutableS } @Composable -fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) { +fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) { val finalColor = if (color == Color.Unspecified) { if (isInDarkTheme()) MenuTextColorDark else Color.Black } else color diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 7af4d2670a..7883a7a738 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1,6 +1,5 @@ package chat.simplex.common.views.chatlist -import SectionItemView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -9,30 +8,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.SettingsViewState import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.usersettings.SettingsView -import chat.simplex.common.views.usersettings.simplexTeamUri import chat.simplex.common.platform.* -import chat.simplex.common.views.call.Call -import chat.simplex.common.views.call.CallMediaType -import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* @@ -77,7 +68,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp var searchInList by rememberSaveable { mutableStateOf("") } val scope = rememberCoroutineScope() - val (userPickerState, scaffoldState, switchingUsers ) = settingsState + val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } }, scaffoldState = scaffoldState, drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) }, @@ -113,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf ) { if (chatModel.chats.isNotEmpty()) { ChatList(chatModel, search = searchInList) - } else if (!switchingUsers.value) { + } else if (!switchingUsersAndHosts.value) { Box(Modifier.fillMaxSize()) { if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) { OnboardingButtons(showNewChatSheet) @@ -129,11 +120,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState, switchingUsers) { + UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + userPickerState.value = AnimatedViewState.GONE } } - if (switchingUsers.value) { + if (switchingUsersAndHosts.value) { Box( Modifier.fillMaxSize().clickable(enabled = false, onClick = {}), contentAlignment = Alignment.Center @@ -224,7 +216,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { - if (users.size == 1) { + if (users.size == 1 && chatModel.remoteHosts.isEmpty()) { scope.launch { drawerState.open() } } else { userPickerState.value = AnimatedViewState.VISIBLE @@ -254,14 +246,25 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user @Composable fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) { - IconButton(onClick = onButtonClicked) { - Box { - ProfileImage( - image = image, - size = 37.dp - ) - if (!allRead) { - unreadBadge() + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onButtonClicked) { + Box { + ProfileImage( + image = image, + size = 37.dp + ) + if (!allRead) { + unreadBadge() + } + } + } + if (appPlatform.isDesktop) { + val h by remember { chatModel.currentRemoteHost } + if (h != null) { + Spacer(Modifier.width(12.dp)) + HostDisconnectButton { + stopRemoteHostAndReloadHosts(h!!, true) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 8b65b2b5bc..ecd47c937a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) { var searchInList by rememberSaveable { mutableStateOf("") } - val (userPickerState, scaffoldState, switchingUsers) = settingsState + val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp Scaffold( Modifier.padding(end = endPadding), @@ -47,8 +47,9 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe } } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = { + UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = { chatModel.sharedContent.value = null + userPickerState.value = AnimatedViewState.GONE }) } } @@ -72,7 +73,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState val navButton: @Composable RowScope.() -> Unit = { when { showSearch -> NavigationButtonBack(hideSearchOnBack) - users.size > 1 -> { + users.size > 1 || chatModel.remoteHosts.isNotEmpty() -> { val allRead = users .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 8c7dc2c605..66cac7204a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -4,10 +4,12 @@ import SectionItemView import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.* import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.Color @@ -18,12 +20,15 @@ import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.User import chat.simplex.common.platform.* +import chat.simplex.common.views.remote.connectMobileDevice import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -33,7 +38,7 @@ import kotlin.math.roundToInt fun UserPicker( chatModel: ChatModel, userPickerState: MutableStateFlow, - switchingUsers: MutableState, + switchingUsersAndHosts: MutableState, showSettings: Boolean = true, showCancel: Boolean = false, cancelClicked: () -> Unit = {}, @@ -53,6 +58,12 @@ fun UserPicker( .sortedByDescending { it.user.activeUser } } } + val remoteHosts by remember { + derivedStateOf { + chatModel.remoteHosts + .sortedBy { it.hostDeviceName } + } + } val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) } LaunchedEffect(Unit) { launch { @@ -90,8 +101,42 @@ fun UserPicker( } catch (e: Exception) { Log.e(TAG, "Error updating users ${e.stackTraceToString()}") } + if (!appPlatform.isDesktop) return@collect + try { + val updatedHosts = chatModel.controller.listRemoteHosts()?.sortedBy { it.hostDeviceName } ?: emptyList() + if (remoteHosts != updatedHosts) { + chatModel.remoteHosts.clear() + chatModel.remoteHosts.addAll(updatedHosts) + } + } catch (e: Exception) { + Log.e(TAG, "Error updating remote hosts ${e.stackTraceToString()}") + } } } + LaunchedEffect(Unit) { + controller.reloadRemoteHosts() + } + val UsersView: @Composable ColumnScope.() -> Unit = { + users.forEach { u -> + UserProfilePickerItem(u.user, u.unreadCount, openSettings = settingsClicked) { + userPickerState.value = AnimatedViewState.HIDING + if (!u.user.activeUser) { + scope.launch { + val job = launch { + delay(500) + switchingUsersAndHosts.value = true + } + ModalManager.closeAllModalsEverywhere() + chatModel.controller.changeActiveUser(u.user.userId, null) + job.cancel() + switchingUsersAndHosts.value = false + } + } + } + Divider(Modifier.requiredHeight(1.dp)) + if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp)) + } + } val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() } val maxWidth = with(LocalDensity.current) { windowWidth() * density } Box(Modifier @@ -113,48 +158,63 @@ fun UserPicker( .background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp))) .clip(RoundedCornerShape(corner = CornerSize(25.dp))) ) { + val currentRemoteHost = remember { chatModel.currentRemoteHost }.value Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - users.forEach { u -> - UserProfilePickerItem(u.user, u.unreadCount, PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), openSettings = { - settingsClicked() - userPickerState.value = AnimatedViewState.GONE - }) { - userPickerState.value = AnimatedViewState.HIDING - if (!u.user.activeUser) { - scope.launch { - val job = launch { - delay(500) - switchingUsers.value = true - } - ModalManager.closeAllModalsEverywhere() - chatModel.controller.changeActiveUser(u.user.userId, null) - job.cancel() - switchingUsers.value = false - } + if (remoteHosts.isNotEmpty()) { + if (currentRemoteHost == null) { + LocalDevicePickerItem(true) { + userPickerState.value = AnimatedViewState.HIDING + switchToLocalDevice() } + Divider(Modifier.requiredHeight(1.dp)) + } else { + val connecting = rememberSaveable { mutableStateOf(false) } + RemoteHostPickerItem(currentRemoteHost, + actionButtonClick = { + userPickerState.value = AnimatedViewState.HIDING + stopRemoteHostAndReloadHosts(currentRemoteHost, true) + }) { + userPickerState.value = AnimatedViewState.HIDING + switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting) + } + Divider(Modifier.requiredHeight(1.dp)) + } + } + + UsersView() + + if (remoteHosts.isNotEmpty() && currentRemoteHost != null) { + LocalDevicePickerItem(false) { + userPickerState.value = AnimatedViewState.HIDING + switchToLocalDevice() + } + Divider(Modifier.requiredHeight(1.dp)) + } + remoteHosts.filter { !it.activeHost }.forEach { h -> + val connecting = rememberSaveable { mutableStateOf(false) } + RemoteHostPickerItem(h, + actionButtonClick = { + userPickerState.value = AnimatedViewState.HIDING + stopRemoteHostAndReloadHosts(h, false) + }) { + userPickerState.value = AnimatedViewState.HIDING + switchToRemoteHost(h, switchingUsersAndHosts, connecting) } Divider(Modifier.requiredHeight(1.dp)) - if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp)) } } if (showSettings) { - SettingsPickerItem { - settingsClicked() - userPickerState.value = AnimatedViewState.GONE - } + SettingsPickerItem(settingsClicked) } if (showCancel) { - CancelPickerItem { - cancelClicked() - userPickerState.value = AnimatedViewState.GONE - } + CancelPickerItem(cancelClicked) } } } } @Composable -fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues = PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) { +fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) { Row( Modifier .fillMaxWidth() @@ -166,7 +226,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues indication = if (!u.activeUser) LocalIndication.current else null ) .onRightClick { onLongClick() } - .padding(padding), + .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -219,16 +279,97 @@ fun UserProfileRow(u: User) { } } +@Composable +fun RemoteHostPickerItem(h: RemoteHostInfo, onLongClick: () -> Unit = {}, actionButtonClick: () -> Unit = {}, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .background(color = if (h.activeHost) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) + .sizeIn(minHeight = 46.dp) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + .onRightClick { onLongClick() } + .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + RemoteHostRow(h) + if (h.sessionState is RemoteHostSessionState.Connected) { + HostDisconnectButton(actionButtonClick) + } else { + Box(Modifier.size(20.dp)) + } + } +} + +@Composable +fun RemoteHostRow(h: RemoteHostInfo) { + Row( + Modifier + .widthIn(max = windowWidth() * 0.7f) + .padding(start = 17.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Text( + h.hostDeviceName, + modifier = Modifier.padding(start = 26.dp, end = 8.dp), + color = if (h.activeHost) MaterialTheme.colors.onBackground else if (isInDarkTheme()) MenuTextColorDark else Color.Black, + fontSize = 14.sp, + ) + } +} + +@Composable +fun LocalDevicePickerItem(active: Boolean, onLongClick: () -> Unit = {}, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .background(color = if (active) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) + .sizeIn(minHeight = 46.dp) + .combinedClickable( + onClick = if (active) {{}} else onClick, + onLongClick = onLongClick, + interactionSource = remember { MutableInteractionSource() }, + indication = if (!active) LocalIndication.current else null + ) + .onRightClick { onLongClick() } + .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + LocalDeviceRow(active) + Box(Modifier.size(20.dp)) + } +} + +@Composable +fun LocalDeviceRow(active: Boolean) { + Row( + Modifier + .widthIn(max = windowWidth() * 0.7f) + .padding(start = 17.dp, end = DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Text( + stringResource(MR.strings.this_device), + modifier = Modifier.padding(start = 26.dp, end = 8.dp), + color = if (active) MaterialTheme.colors.onBackground else if (isInDarkTheme()) MenuTextColorDark else Color.Black, + fontSize = 14.sp, + ) + } +} + @Composable private fun SettingsPickerItem(onClick: () -> Unit) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current) Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) - Text( - text, - color = if (isInDarkTheme()) MenuTextColorDark else Color.Black, - ) + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) } } @@ -238,9 +379,47 @@ private fun CancelPickerItem(onClick: () -> Unit) { val text = generalGetString(MR.strings.cancel_verb) Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) - Text( - text, - color = if (isInDarkTheme()) MenuTextColorDark else Color.Black, + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) + } +} + +@Composable +fun HostDisconnectButton(onClick: (() -> Unit)?) { + val interactionSource = remember { MutableInteractionSource() } + val hovered = interactionSource.collectIsHoveredAsState().value + IconButton(onClick ?: {}, Modifier.requiredSize(20.dp), enabled = onClick != null) { + Icon( + painterResource(if (onClick == null) MR.images.ic_desktop else if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi), + null, + Modifier.size(20.dp).hoverable(interactionSource), + tint = if (hovered && onClick != null) WarningOrange else MaterialTheme.colors.onBackground ) } } + +private fun switchToLocalDevice() { + withBGApi { + chatController.switchUIRemoteHost(null) + } +} + +private fun switchToRemoteHost(h: RemoteHostInfo, switchingUsersAndHosts: MutableState, connecting: MutableState) { + if (!h.activeHost()) { + withBGApi { + val job = launch { + delay(500) + switchingUsersAndHosts.value = true + } + ModalManager.closeAllModalsEverywhere() + if (h.sessionState != null) { + chatModel.controller.switchUIRemoteHost(h.remoteHostId) + } else { + connectMobileDevice(h, connecting) + } + job.cancel() + switchingUsersAndHosts.value = false + } + } else { + connectMobileDevice(h, connecting) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 0f930b3126..703b6f905f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -12,6 +12,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.min @Composable fun ModalView( @@ -86,7 +87,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun closeModal() { if (modalViews.isNotEmpty()) { if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex) - else runAtomically { toRemove.add(modalViews.lastIndex - toRemove.size) } + else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) } } modalCount.value = modalViews.size - toRemove.size } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt new file mode 100644 index 0000000000..d00b9bb67a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -0,0 +1,359 @@ +package chat.simplex.common.views.remote + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewLongClickable +import SectionTextFooter +import SectionView +import TextIconSpaced +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.* +import androidx.compose.ui.text.input.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.usersettings.SettingsActionItemWithContent +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ConnectMobileView( + m: ChatModel +) { + val connecting = rememberSaveable() { mutableStateOf(false) } + val remoteHosts = remember { chatModel.remoteHosts } + val deviceName = m.controller.appPrefs.deviceNameForRemoteAccess + LaunchedEffect(Unit) { + controller.reloadRemoteHosts() + } + ConnectMobileLayout( + deviceName = remember { deviceName.state }, + remoteHosts = remoteHosts, + connecting, + connectedHost = remember { m.currentRemoteHost }, + updateDeviceName = { + withBGApi { + if (it != "") { + m.controller.setLocalDeviceName(it) + deviceName.set(it) + } + } + }, + addMobileDevice = { showAddingMobileDevice(connecting) }, + connectMobileDevice = { connectMobileDevice(it, connecting) }, + connectDesktop = { withBGApi { chatController.switchUIRemoteHost(null) } }, + deleteHost = { host -> + withBGApi { + val success = controller.deleteRemoteHost(host.remoteHostId) + if (success) { + chatModel.remoteHosts.removeAll { it.remoteHostId == host.remoteHostId } + } + } + } + ) +} + +@Composable +fun ConnectMobileLayout( + deviceName: State, + remoteHosts: List, + connecting: MutableState, + connectedHost: State, + updateDeviceName: (String) -> Unit, + addMobileDevice: () -> Unit, + connectMobileDevice: (RemoteHostInfo) -> Unit, + connectDesktop: () -> Unit, + deleteHost: (RemoteHostInfo) -> Unit, +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles)) + SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { + DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } + SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) + SectionDividerSpaced(maxBottomPadding = false) + } + SectionView(stringResource(MR.strings.devices).uppercase()) { + SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) { + if (connectedHost.value == null) { + Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } + } + + for (host in remoteHosts) { + val showMenu = rememberSaveable { mutableStateOf(false) } + SectionItemViewLongClickable({ connectMobileDevice(host) }, { showMenu.value = true }, disabled = connecting.value) { + Icon(painterResource(MR.images.ic_smartphone_300), host.hostDeviceName, tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(host.hostDeviceName) + Spacer(Modifier.weight(1f)) + if (host.activeHost) { + Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } else if (host.sessionState is RemoteHostSessionState.Connected) { + HostDisconnectButton { stopRemoteHostAndReloadHosts(host, false) } + } + } + Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { + DefaultDropdownMenu(showMenu) { + if (host.activeHost) { + ItemAction(stringResource(MR.strings.disconnect_remote_host), painterResource(MR.images.ic_wifi_off), color = WarningOrange) { + stopRemoteHostAndReloadHosts(host, true) + showMenu.value = false + } + } else { + ItemAction(stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), color = Color.Red) { + deleteHost(host) + showMenu.value = false + } + } + } + } + } + SectionItemView(addMobileDevice) { + Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.link_a_mobile), tint = MaterialTheme.colors.primary) + Spacer(Modifier.padding(horizontal = 10.dp)) + Text(stringResource(MR.strings.link_a_mobile), color = MaterialTheme.colors.primary) + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun DeviceNameField( + initialValue: String, + onChange: (String) -> Unit +) { + // TODO get user-defined device name + val state = remember { mutableStateOf(TextFieldValue(initialValue)) } + DefaultConfigurableTextField( + state = state, + placeholder = generalGetString(MR.strings.enter_this_device_name), + modifier = Modifier.padding(start = DEFAULT_PADDING), + isValid = { true }, + ) + KeyChangeEffect(state.value) { + onChange(state.value.text) + } +} + +@Composable +private fun ConnectMobileViewLayout( + title: String, + invitation: String?, + deviceName: String?, + sessionCode: String? +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppBarTitle(title) + SectionView { + if (invitation != null && sessionCode == null) { + QRCode( + invitation, Modifier + .padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF) + .aspectRatio(1f) + ) + SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code)) + + if (remember { controller.appPrefs.developerTools.state }.value) { + val clipboard = LocalClipboardManager.current + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + SectionItemView({ clipboard.shareText(invitation) }) { + Text(generalGetString(MR.strings.share_link), color = MaterialTheme.colors.primary) + } + } + + Spacer(Modifier.height(DEFAULT_PADDING)) + } + if (deviceName != null || sessionCode != null) { + SectionView(stringResource(MR.strings.connected_mobile).uppercase()) { + SelectionContainer { + Text( + deviceName ?: stringResource(MR.strings.new_mobile_device), + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp, fontStyle = if (deviceName != null) FontStyle.Normal else FontStyle.Italic) + ) + } + } + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + } + + if (sessionCode != null) { + SectionView(stringResource(MR.strings.verify_code_on_mobile).uppercase()) { + SelectionContainer { + Text( + sessionCode.substring(0, 23), + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp) + ) + } + } + } + } + } +} + +fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { + if (!rh.activeHost() && rh.sessionState is RemoteHostSessionState.Connected) { + withBGApi { + controller.switchUIRemoteHost(rh.remoteHostId) + } + } else if (rh.activeHost()) { + showConnectedMobileDevice(rh) { + stopRemoteHostAndReloadHosts(rh, true) + } + } else { + showConnectMobileDevice(rh, connecting) + } +} + +private fun showAddingMobileDevice(connecting: MutableState) { + ModalManager.start.showModalCloseable { close -> + val invitation = rememberSaveable { mutableStateOf(null) } + val pairing = remember { chatModel.newRemoteHostPairing } + val sessionCode = when (val state = pairing.value?.second) { + is RemoteHostSessionState.PendingConfirmation -> state.sessionCode + else -> null + } + /** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */ + var cachedSessionCode by remember { mutableStateOf(null) } + if (cachedSessionCode == null && sessionCode != null) { + cachedSessionCode = sessionCode + } + val remoteDeviceName = pairing.value?.first?.hostDeviceName + ConnectMobileViewLayout( + title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection), + invitation = invitation.value, + deviceName = remoteDeviceName, + sessionCode = cachedSessionCode + ) + val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) } + LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { + if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) { + close() + } + } + KeyChangeEffect(pairing.value) { + if (pairing.value == null) { + close() + } + } + DisposableEffect(Unit) { + withBGApi { + val r = chatModel.controller.startRemoteHost(null) + if (r != null) { + connecting.value = true + invitation.value = r.second + } + } + onDispose { + if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) { + withBGApi { + chatController.stopRemoteHost(null) + } + } + chatModel.newRemoteHostPairing.value = null + } + } + } +} + +private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { + ModalManager.start.showModalCloseable { close -> + val pairing = remember { chatModel.newRemoteHostPairing } + val invitation = rememberSaveable { mutableStateOf(null) } + val sessionCode = when (val state = pairing.value?.second) { + is RemoteHostSessionState.PendingConfirmation -> state.sessionCode + else -> null + } + /** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */ + var cachedSessionCode by remember { mutableStateOf(null) } + if (cachedSessionCode == null && sessionCode != null) { + cachedSessionCode = sessionCode + } + ConnectMobileViewLayout( + title = if (cachedSessionCode == null) stringResource(MR.strings.scan_from_mobile) else stringResource(MR.strings.verify_connection), + invitation = invitation.value, + deviceName = pairing.value?.first?.hostDeviceName ?: rh.hostDeviceName, + sessionCode = cachedSessionCode, + ) + var remoteHostId by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(Unit) { + val r = chatModel.controller.startRemoteHost(rh.remoteHostId) + if (r != null) { + val (rh_, inv) = r + connecting.value = true + remoteHostId = rh_?.remoteHostId + invitation.value = inv + } + } + LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { + if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId) { + close() + } + } + KeyChangeEffect(pairing.value) { + if (pairing.value == null) { + close() + } + } + DisposableEffect(Unit) { + onDispose { + if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != remoteHostId) { + withBGApi { + chatController.stopRemoteHost(remoteHostId) + } + } + chatModel.newRemoteHostPairing.value = null + } + } + } +} + +private fun showConnectedMobileDevice(rh: RemoteHostInfo, disconnectHost: () -> Unit) { + ModalManager.start.showModalCloseable { close -> + val sessionCode = when (val state = rh.sessionState) { + is RemoteHostSessionState.Connected -> state.sessionCode + else -> null + } + Column { + ConnectMobileViewLayout( + title = stringResource(MR.strings.connected_to_mobile), + invitation = null, + deviceName = rh.hostDeviceName, + sessionCode = sessionCode + ) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + SectionItemView(disconnectHost) { + Text(generalGetString(MR.strings.disconnect_remote_host), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + KeyChangeEffect(remember { chatModel.currentRemoteHost }.value) { + close() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index d949f800bb..5fa3c41478 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -29,6 +29,7 @@ import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.remote.ConnectMobileView import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -155,6 +156,9 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) ChatPreferencesItem(showCustomModal, stopped = stopped) + if (appPlatform.isDesktop) { + SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView(it) }, disabled = stopped, extraPadding = true) + } } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 170a28f3d6..9889d6ab70 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1625,6 +1625,24 @@ You can enable them later via app Privacy & Security settings. Error enabling delivery receipts! + + Link a mobile + Linked mobiles + Scan from mobile + Verify connection + Verify code on mobile + This device name + Connected mobile + Connected to mobile + Enter this device name… + The device name will be shared with the connected mobile client. + Error + This device + Devices + New mobile device + Disconnect + Use from desktop in mobile app and scan QR code]]> + Coming soon! This feature is not yet supported. Try the next release. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg new file mode 100644 index 0000000000..93094d1445 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg new file mode 100644 index 0000000000..7d8553db11 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg new file mode 100644 index 0000000000..2fb4750af5 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg new file mode 100644 index 0000000000..814077e485 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flake.nix b/flake.nix index 3ef1913fd9..14b7ae7310 100644 --- a/flake.nix +++ b/flake.nix @@ -337,6 +337,7 @@ "chat_recv_msg" "chat_recv_msg_wait" "chat_send_cmd" + "chat_send_remote_cmd" "chat_valid_name" "chat_write_file" ]; @@ -435,6 +436,7 @@ "chat_recv_msg" "chat_recv_msg_wait" "chat_send_cmd" + "chat_send_remote_cmd" "chat_valid_name" "chat_write_file" ]; diff --git a/libsimplex.dll.def b/libsimplex.dll.def index 755119fca8..2d6e813d77 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -3,6 +3,7 @@ EXPORTS hs_init chat_migrate_init chat_send_cmd + chat_send_remote_cmd chat_recv_msg chat_recv_msg_wait chat_parse_markdown From d0f3a3d886df605ae102d29a42c67e0c99b39c92 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Nov 2023 21:53:54 +0000 Subject: [PATCH 198/294] rfc: remote UI implementation (#3206) --- docs/rfcs/2023-09-12-remote-profile.md | 25 ++++++-- docs/rfcs/2023-10-12-remote-ui.md | 88 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 docs/rfcs/2023-10-12-remote-ui.md diff --git a/docs/rfcs/2023-09-12-remote-profile.md b/docs/rfcs/2023-09-12-remote-profile.md index 9a41d55d5a..a36c1bfcf6 100644 --- a/docs/rfcs/2023-09-12-remote-profile.md +++ b/docs/rfcs/2023-09-12-remote-profile.md @@ -125,9 +125,10 @@ Alternatively a mobile (or a desktop, why not) may signal that they're done here At any time a user may click on a "cancel" button and return to the main UI. That should fully re-initialise UI state. -In the "Network & servers" section of "Settings", there is an item to list all the registered remote controllers with buttons attached to *dispose* them one by one. -*Disposing* a remote controller means its entry will be removed from database. -Future connection attempts from a disposed device would be treated exactly as from a previously-unknown device. +This screen should have a way to open the list of all known remote controllers (desktop devices), to allow removing them. + +*Removing* remote controller means its entry will be removed from database. +Future connection attempts with a removed desktop will be treated as with a previously unknown device. ### On a desktop device @@ -148,8 +149,9 @@ Future connection attempts from a disposed device would be treated exactly as fr 4. A user may open sidebar and click "disconnect from mobile" to close the session and return to local mode. * That should fully re-initialise UI state. -In the "Network & servers" section of "Settings", there is an item to list all the registered remote hosts with buttons attached to *dispose* them one by one. -*Disposing* a remote host means its entry will be removed from database and any associated files deleted (photos, voice messages, transferred files etc). +Unlike mobile UI, removing known mobiles should happen via the same screen that shows connected mobile deivices. + +*Removing* a remote host means its entry will be removed from database and any associated files deleted (photos, voice messages, transferred files etc). ## Caveats @@ -192,9 +194,20 @@ A backup system may be implemented by attaching a headless app to a bouncer as o The unauthenticated remote host can be considered a feature. A use case for that may be something like a "dead drop" host that wakes up in response to any discovery broadcast. -## Unresolved questions +## Other questions - What to do with WebRTC/calls? + +Calls use local desktop implementation, they will use host for signalling. + - Do we want attaching only to a subset of profiles? + +No. + - Do we want a client to mix remote and local profiles? + +No. + - Do we want M-to-N sessions? (follows naturally from the previous two) + +No. diff --git a/docs/rfcs/2023-10-12-remote-ui.md b/docs/rfcs/2023-10-12-remote-ui.md new file mode 100644 index 0000000000..308babf5cd --- /dev/null +++ b/docs/rfcs/2023-10-12-remote-ui.md @@ -0,0 +1,88 @@ +# Remote desktop / mobile implementation details + +This follows the previous RFC for [remote profiles](./2023-09-12-remote-profile.md). + +Code uses terms remote controller and remote host to designate the roles, and CLI client can support both. + +This document uses the terms "mobile" to mean remote host and "desktop" to mean remote controller, mobile apps will only support "remote host" role (via UI, and, possibly, via the compilation flag to remove this functionality from the code), and desktop will only support "remote controller" role. + +## Mobile (host) + +UX is described [here](./2023-09-12-remote-profile.md#on-a-mobile-device). + +When mobile is connected to the remote desktop it will receive no events from the core (other than remote session connection status changes), and the core will accept no commands from the UI (other than to terminate the remote session). + +As currently mobile maintains the list of connection statuses for all profiles, this state will have to be maintained in Haskell to be able to send it to the remote desktop UI when it is connected. It will also be used to update mobile UI when control is returned to the mobile. + +The switching between remote host role and local UI role should prevent the possibility of any race conditions. + +To swith to remote host role: +- UI: will show the screen with "connected" status and "cancel" button, with disabled sleep timer on iOS, as iOS app will have to stay in foregro. Android will be able to function in background in this state. +- core: stop adding events to the output queues (via some flag). +- UI: process remaining events in the output queue and stop receiving them. +- core: stop sending events to and accepting commands from UI. +- core: send current list of profiles, coversations, and connections statuses to the remote desktop. +- core: start sending events to and accepting commands from remote desktop. +- core: start adding events to remote output queue. + +To switch back to local UI role: +- core: stop adding events to the output queues. +- core: stop receiving commands from and sending events to remote desktop. +- remote desktop: receive pending events and stop processing them. +- UI: load current list of profiles, conversations, and connection statuses from the core. +- UI: start receiving events +- core: start sending events to and accepting commands from local UI. +- core: start adding events to UI local remote output queue. + +Possibly, there is a simpler way to switch, but it is important that the new events are applied to the loaded state, to avoid state being out of sync. + +## Desktop (controller) + +Desktop can either control local profiles ("local host" term is used) or remote host(s)/mobile(s). Only one host, local or remote, can be controlled at a time. It is important though to be able to receive notifications from multiple hosts, at the very least from local and mobile, as the important use case is this: + +- mobile device only has contacts and important groups to save the traffic and battery. +- desktop has large public groups. + +So while reading large public groups the users should be able to receive notifications from both mobile device and local profile(s). + +That means that while only one host can be active in desktop, multiple hosts can be connected. + +Current UI model contains: +- the list of the conversations for the chosen profile. +- the list of the user profiles. +- the statuses of connections across all profiles - this is maintained because the core does not track connection statuses. + +As the core will start maintaining the connection statuses, as a possible optimisation we could reduce the connections in the UI to only the current profile and reset it every time the profile is switched. + +In addition to the above, the UI model will need to contain the list of connected remote hosts (mobiles), so that the user can switch between them. + +Switching profile currently updates the list of conversations. If connection statuses are narrowed to the current profile only, they will have to be updated as well. + +When switching host (whether to local or to remote), the UI will have to: +- update the list of profiles +- update the list of conversations for the active profile in the host +- update connection statuses, either for all profiles or for the active profile only - TBC + +When connected to remote host, or possibly always, UI will have to use the extended FFI to indicate the host in all commands (e.g., to allow automatic file reception in inactive hosts) - as the core cannot simply assume which host is active. Probably, some of the commands (most of them) should require the host to match the currently active host in the core, file reception will not require that. + +### Onboarding and "only-remote" desktop + +Majority of the users want to use desktop in "remote-only" role, when there is no local profile. Currently, it is a global assumption that the core has an active profile for most commands. Possible solutions to that are: + +- update core to allow functioning without local profile. +- create an invisible phantom profile that won't be shown in the UI but will be used by the core to process commands related to remote hosts (connected mobiles). + +The 2nd option seems simpler. The phantom profile will use an empty string as display name, and, possibly an additional flag to mark it as phantom. Once a real profile is created this phatom one can be removed (or can be just abandoned). It might be better to block most commands for this phantom profile? + +Onboarding on desktop will need to be re-implemented to offer connecting to mobile as primary option and creating a local profile as secondary, and from the users' point of view they will not be creating any local profiles. + +## Loading files + +Currently active UI, either remote (desktop) or local (mobile), will be making the decision to auto-receive file, based on its own local settings. It seems a better trade-off than alternatives, and also allows for different auto-receive configurations based on the device. + +Forwarding received and uploading sent files to/from desktop is already implemented. + +It is still required to implement both commands API in FFI layer and, possibly, HTTP API to download files to desktop when they are: +- shown in the UI for images. +- played for voice and video messages. +- saved for any other files. From 0d7a048988bc05db6a4fba1f7f96bce35bf6d7d5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Nov 2023 22:54:54 +0000 Subject: [PATCH 199/294] nix: patches for armv7a, fix segfault issues, etc (#3383) * bump mobile-core-tools * Update deps * :facepalm: * patch * needs p * 32bit patches * bump haskell.nix * bump again * fix broken flake.lock * bump haskell.nix * bump haskell.nix (to fix darwin) --------- Co-authored-by: Moritz Angermann --- flake.lock | 24 ++++++++++++------------ flake.nix | 25 +++++++++++++++++++++---- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/flake.lock b/flake.lock index 5f2cde2bdd..c482f7ca9b 100644 --- a/flake.lock +++ b/flake.lock @@ -190,11 +190,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1698020746, - "narHash": "sha256-/eFVtqLu4sGfjJiwmelfH2qK0ZGFOmyCxBTJ1ncOeSI=", + "lastModified": 1699834964, + "narHash": "sha256-733KT+G0c1euCeb60/u1qbX22Kvu9lNnIDfAmk6Jxq0=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "dc3cf3bbc0b1e9780e008f9cd6af4a9aeb344ad7", + "rev": "2e891e530400187ea1083ffef15adf259061be41", "type": "github" }, "original": { @@ -240,11 +240,11 @@ "stackage": "stackage" }, "locked": { - "lastModified": 1698666250, - "narHash": "sha256-nt8n0N//L7A6T9tgPVAp0Z2JurVDh/DveN2pAIsvIMI=", + "lastModified": 1700119633, + "narHash": "sha256-nZY2eIo8TkRbXgJXEWMm9zor330GuUtcNzvUN9tN64U=", "owner": "input-output-hk", "repo": "haskell.nix", - "rev": "cc58b1a8e0faeb1a4968a91affe0bcc21632f138", + "rev": "1fe47a3d52e1ecd6247c8ab83811a21de2e2f074", "type": "github" }, "original": { @@ -417,11 +417,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1698662474, - "narHash": "sha256-VoMvl+N1K8LuWO8rjlm6ko//1/l35InxFTrtNSKRc20=", + "lastModified": 1699767871, + "narHash": "sha256-kxeCUfwC/Vgh2FvVMlBUq0eVx1JvfHyN+5MPKUik9mE=", "owner": "zw3rk", "repo": "mobile-core-tools", - "rev": "00d48cc5a929af4b047604805dd8aba9933b37e4", + "rev": "4dcb77d5ea896d749381806dfab5358851b08951", "type": "github" }, "original": { @@ -661,11 +661,11 @@ "stackage": { "flake": false, "locked": { - "lastModified": 1698278970, - "narHash": "sha256-CogUKaZr3d4cJTeqk/u04Ct2fg1EHBCuN3SrWbNYsuM=", + "lastModified": 1699834215, + "narHash": "sha256-g/JKy0BCvJaxPuYDl3QVc4OY8cFEomgG+hW/eEV470M=", "owner": "input-output-hk", "repo": "stackage.nix", - "rev": "d0599a4c08237202eb560aa2d2625b5daa818cc6", + "rev": "47aacd04abcce6bad57f43cbbbd133538380248e", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 3ef1913fd9..b56756922f 100644 --- a/flake.nix +++ b/flake.nix @@ -127,8 +127,22 @@ hardeningDisable = [ "fortify" ]; } );in { + # STATIC x86_64-linux "${pkgs.pkgsCross.musl64.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.musl64).simplex-chat.components.exes.simplex-chat; - "${pkgs.pkgsCross.musl32.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.musl32).simplex-chat.components.exes.simplex-chat; + # STATIC i686-linux + "${pkgs.pkgsCross.musl32.hostPlatform.system}-static:exe:simplex-chat" = (drv' { + pkgs' = pkgs.pkgsCross.musl32; + extra-modules = [{ + # 32 bit patches + packages.basement.patches = [ + ./scripts/nix/basement-pr-573.patch + ]; + packages.memory.patches = [ + ./scripts/nix/memory-pr-99.patch + ]; + }]; + }).simplex-chat.components.exes.simplex-chat; + # WINDOWS x86_64-mingwW64 "${pkgs.pkgsCross.mingwW64.hostPlatform.system}:exe:simplex-chat" = (drv' { pkgs' = pkgs.pkgsCross.mingwW64; extra-modules = [{ @@ -229,15 +243,17 @@ ''; }); # "${pkgs.pkgsCross.muslpi.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.muslpi).simplex-chat.components.exes.simplex-chat; + + # STATIC aarch64-linux "${pkgs.pkgsCross.aarch64-multiplatform-musl.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.aarch64-multiplatform-musl).simplex-chat.components.exes.simplex-chat; - "armv7a-android:lib:support" = (drv android32Pkgs).android-support.components.library.override { + "armv7a-android:lib:support" = (drv android32Pkgs).android-support.components.library.override (p: { smallAddressSpace = true; # we won't want -dyamic (see aarch64-android:lib:simplex-chat) enableShared = false; # we also do not want to have any dependencies listed (especially no rts!) enableStatic = false; - setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; + setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; postInstall = '' mkdir -p $out/_pkg @@ -250,7 +266,7 @@ echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }; + }); # The android-support package is at # https://github.com/simplex-chat/android-support "aarch64-android:lib:support" = (drv androidPkgs).android-support.components.library.override (p: { @@ -293,6 +309,7 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + # 32 bit patches packages.basement.patches = [ ./scripts/nix/basement-pr-573.patch ]; From cf102da4d35cda4516ad42e5ca37482031647446 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 17 Nov 2023 13:19:33 +0200 Subject: [PATCH 200/294] remote: add test for rejected ca detection and stability (#3382) * add test for rejected ca detection and stability * update mq commit --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- tests/RemoteTests.hs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index b7baee588d..642f3d1021 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 3b348a463cd83fbd803743b1d67f282a42d8b654 + tag: c501f4f9ccdd48807a5153697ea1827129841158 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 5f65742887..c211fc99e6 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3b348a463cd83fbd803743b1d67f282a42d8b654" = "1rbd5zz1rclnfvjf68ll5qhi9yqk040bi491z10hwyhxi2bixpaw"; + "https://github.com/simplex-chat/simplexmq.git"."c501f4f9ccdd48807a5153697ea1827129841158" = "1s99mjc7rjk9wg14m5xddw64a3mlr8l7ba9mclma598hg73l0vaw"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/stack.yaml b/stack.yaml index 4ef1c422ec..f3d98d7f65 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 3b348a463cd83fbd803743b1d67f282a42d8b654 + commit: c501f4f9ccdd48807a5153697ea1827129841158 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 0d3dc74627..db19cac519 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -36,6 +36,7 @@ remoteTests = describe "Remote" $ do it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False it "connects with new pairing (stops desktop)" $ remoteHandshakeTest True it "connects with stored pairing" remoteHandshakeStoredTest + it "refuses invalid client cert" remoteHandshakeRejectTest it "sends messages" remoteMessageTest describe "remote files" $ do it "store/get/send/receive files" remoteStoreFileTest @@ -95,6 +96,36 @@ remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile startRemoteStored mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) +remoteHandshakeRejectTest :: HasCallStack => FilePath -> IO () +remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop mobileBob -> do + logNote "Starting new session" + startRemote mobile desktop + stopMobile mobile desktop + + mobileBob ##> "/set device name MobileBob" + mobileBob <## "ok" + desktop ##> "/start remote host 1" + desktop <## "remote host 1 started" + desktop <## "Remote session invitation:" + inv <- getTermLine desktop + mobileBob ##> ("/connect remote ctrl " <> inv) + mobileBob <## "connecting new remote controller: My desktop, v5.4.0.3" + mobileBob <## "remote controller stopped" + + -- the server remains active after rejecting invalid client + mobile ##> ("/connect remote ctrl " <> inv) + mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + desktop <## "remote host 1 connecting" + desktop <## "Compare session code with host:" + sessId <- getTermLine desktop + mobile <## "remote controller 1 connected" + mobile <## "Compare session code with controller and use:" + mobile <## ("/verify remote ctrl " <> sessId) + mobile ##> ("/verify remote ctrl " <> sessId) + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + stopMobile mobile desktop + remoteMessageTest :: HasCallStack => FilePath -> IO () remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop From f6c4e969e4d2ae6a59c50b93ed666bf1e4e4660c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 17 Nov 2023 13:28:10 +0000 Subject: [PATCH 201/294] nix: add openssl to simplexmq, swift flag to simplex-chat (#3386) * nix: add swift flag * add openssl for simplexmq to nix * add openssl to android simplemq, try iOS with enableKTLS = false flag * fix android --- flake.nix | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/flake.nix b/flake.nix index e14e57ca75..4466fa110e 100644 --- a/flake.nix +++ b/flake.nix @@ -154,6 +154,9 @@ packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ (pkgs.pkgsCross.mingwW64.openssl) #.override) # { static = true; enableKTLS = false; }) ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.pkgsCross.mingwW64.openssl) #.override) # { static = true; enableKTLS = false; }) + ]; packages.unix-time.postPatch = '' sed -i 's/mingwex//g' unix-time.cabal ''; @@ -185,6 +188,9 @@ packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ pkgs.pkgsCross.mingwW64.openssl ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + pkgs.pkgsCross.mingwW64.openssl + ]; packages.unix-time.postPatch = '' sed -i 's/mingwex//g' unix-time.cabal ''; @@ -309,6 +315,9 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) + ]; # 32 bit patches packages.basement.patches = [ ./scripts/nix/basement-pr-573.patch @@ -413,6 +422,9 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (androidPkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override (p: { smallAddressSpace = true; @@ -509,9 +521,13 @@ "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ + packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-swift-json" @@ -522,6 +538,9 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-tagged-json" @@ -532,9 +551,13 @@ "x86_64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ + packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-swift-json" @@ -545,6 +568,9 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-tagged-json" From 84e09f195c710d366d29d8ecb13930b37b5b7ecf Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 18 Nov 2023 02:19:02 +0800 Subject: [PATCH 202/294] desktop (windows): fix build of CLI (#3387) --- .github/workflows/build.yml | 9 +++++++++ scripts/desktop/build-lib-windows.sh | 13 +++---------- scripts/desktop/prepare-openssl-windows.sh | 21 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 scripts/desktop/prepare-openssl-windows.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6687f47977..592e03257b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -266,6 +266,15 @@ jobs: if: matrix.os == 'windows-latest' shell: bash run: | + scripts/desktop/prepare-openssl-windows.sh + openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') + rm cabal.project.local 2>/dev/null || true + echo "ignore-project: False" >> cabal.project.local + echo "package direct-sqlcipher" >> cabal.project.local + echo " flags: +openssl" >> cabal.project.local + echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local + echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local + rm -rf dist-newstyle/src/direct-sq* sed -i "s/, unix /--, unix /" simplex-chat.cabal cabal build --enable-tests diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index 881e0aea2a..bd2cdc1c23 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -30,16 +30,9 @@ BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-*/simplex-chat-* cd $root_dir mkdir dist-newstyle 2>/dev/null || true -if [ ! -f dist-newstyle/openssl-1.1.1w/libcrypto-1_1-x64.dll ]; then - cd dist-newstyle - curl https://www.openssl.org/source/openssl-1.1.1w.tar.gz -o openssl.tar.gz - $WINDIR\\System32\\tar.exe -xvzf openssl.tar.gz - cd openssl-1.1.1w - ./Configure mingw64 - make - cd ../../ -fi -openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') +scripts/desktop/prepare-openssl-windows.sh + +openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') rm -rf $BUILD_DIR 2>/dev/null || true # Existence of this directory produces build error: cabal's bug rm -rf dist-newstyle/src/direct-sq* 2>/dev/null || true diff --git a/scripts/desktop/prepare-openssl-windows.sh b/scripts/desktop/prepare-openssl-windows.sh new file mode 100644 index 0000000000..79822d3ff5 --- /dev/null +++ b/scripts/desktop/prepare-openssl-windows.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" + +cd $root_dir + +if [ ! -f dist-newstyle/openssl-1.1.1w/libcrypto-1_1-x64.dll ]; then + mkdir dist-newstyle 2>/dev/null || true + cd dist-newstyle + curl https://www.openssl.org/source/openssl-1.1.1w.tar.gz -o openssl.tar.gz + $WINDIR\\System32\\tar.exe -xvzf openssl.tar.gz + cd openssl-1.1.1w + ./Configure mingw64 + make + cd ../../ +fi From 79064e149a0191ddf49e11c1209f33dfb48500e0 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 18 Nov 2023 02:19:38 +0800 Subject: [PATCH 203/294] desktop: enabled smooth scrolling again (#3388) --- .../src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 6b81209d4c..2931e0e014 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -29,10 +29,6 @@ import java.io.File val simplexWindowState = SimplexWindowState() fun showApp() = application { - // TODO: remove after update to compose 1.5.0+ - // See: https://github.com/JetBrains/compose-multiplatform/issues/3366#issuecomment-1643799976 - System.setProperty("compose.scrolling.smooth.enabled", "false") - // For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366, // it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here val width = if (desktopPlatform.isLinux()) 1376.dp else 1366.dp From 42e040001461c11ccdcd283c7c91b3bfc755927d Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 17 Nov 2023 20:50:38 +0200 Subject: [PATCH 204/294] core: add remote controller discovery with multicast (#3369) * draft multicast chat api * prepare tests * Plug discovery into chat api * Add discovery timeout * post-merge fixes * rename discovery state to match others * update for unified invitation * fix review notices * rename, remove stack, update simplexmq --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 6 ++- src/Simplex/Chat/Controller.hs | 10 +++- src/Simplex/Chat/Remote.hs | 91 ++++++++++++++++++++++------------ src/Simplex/Chat/View.hs | 1 + stack.yaml | 2 +- tests/RemoteTests.hs | 46 ++++++++++++++--- 8 files changed, 115 insertions(+), 45 deletions(-) diff --git a/cabal.project b/cabal.project index 642f3d1021..4583237971 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: c501f4f9ccdd48807a5153697ea1827129841158 + tag: 40ba94ce72fb4273641c56fd4c60cd133a24925a source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index c211fc99e6..0ccb77a743 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."c501f4f9ccdd48807a5153697ea1827129841158" = "1s99mjc7rjk9wg14m5xddw64a3mlr8l7ba9mclma598hg73l0vaw"; + "https://github.com/simplex-chat/simplexmq.git"."40ba94ce72fb4273641c56fd4c60cd133a24925a" = "0vqjk4c5vd32y92myv6xr4jhipqza6n08qpii4a0xw6ssm5dgc88"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e2439f69d6..feda221a0c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1967,10 +1967,12 @@ processChatCommand = \case StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath GetRemoteFile rh rf -> withUser_ $ getRemoteFile rh rf >> ok_ ConnectRemoteCtrl inv -> withUser_ $ do - (remoteCtrl_, ctrlAppInfo) <- connectRemoteCtrl inv + (remoteCtrl_, ctrlAppInfo) <- connectRemoteCtrlURI inv pure CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion = currentAppVersion} FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl >> ok_ - ConfirmRemoteCtrl rc -> withUser_ $ confirmRemoteCtrl rc >> ok_ + ConfirmRemoteCtrl rcId -> withUser_ $ do + (rc, ctrlAppInfo) <- confirmRemoteCtrl rcId + pure CRRemoteCtrlConnecting {remoteCtrl_ = Just rc, ctrlAppInfo, appVersion = currentAppVersion} VerifyRemoteCtrlSession sessId -> withUser_ $ CRRemoteCtrlConnected <$> verifyRemoteCtrlSession (execChatCommand Nothing) sessId StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 68d909f784..2ff9b078c6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -75,7 +75,7 @@ import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import Simplex.RemoteControl.Client -import Simplex.RemoteControl.Invitation (RCSignedInvitation) +import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) @@ -1061,6 +1061,8 @@ data RemoteCtrlError | RCEBadState -- ^ A session is in a wrong state for the current operation | RCEBusy -- ^ A session is already running | RCETimeout + | RCENoKnownControllers -- ^ No previously-contacted controllers to discover + | RCEBadController -- ^ Attempting to confirm a found controller with another ID | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller | RCEBadInvitation | RCEBadVersion {appVersion :: AppVersion} @@ -1076,6 +1078,10 @@ data ArchiveError -- | Host (mobile) side of transport to process remote commands and forward notifications data RemoteCtrlSession = RCSessionStarting + | RCSessionSearching + { action :: Async (), + foundCtrl :: TMVar (RemoteCtrl, RCVerifiedInvitation) + } | RCSessionConnecting { remoteCtrlId_ :: Maybe RemoteCtrlId, rcsClient :: RCCtrlClient, @@ -1101,6 +1107,7 @@ data RemoteCtrlSession data RemoteCtrlSessionState = RCSStarting + | RCSSearching | RCSConnecting | RCSPendingConfirmation {sessionCode :: Text} | RCSConnected {sessionCode :: Text} @@ -1109,6 +1116,7 @@ data RemoteCtrlSessionState rcsSessionState :: RemoteCtrlSession -> RemoteCtrlSessionState rcsSessionState = \case RCSessionStarting -> RCSStarting + RCSessionSearching {} -> RCSSearching RCSessionConnecting {} -> RCSConnecting RCSessionPendingConfirmation {tls} -> RCSPendingConfirmation {sessionCode = tlsSessionCode tls} RCSessionConnected {tls} -> RCSConnected {sessionCode = tlsSessionCode tls} diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 331e3348a4..f1ff0cada7 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -8,7 +8,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} -{-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Remote where @@ -28,12 +27,13 @@ import qualified Data.ByteString.Base64.URL as B64U import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) +import Data.List.NonEmpty (nonEmpty) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) -import Data.Word (Word16, Word32) +import Data.Word (Word32) import qualified Network.HTTP.Types as N import Network.HTTP2.Server (responseStreaming) import qualified Paths_simplex_chat as SC @@ -54,18 +54,16 @@ import Simplex.Chat.Util (encryptFile) import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Protocol (AgentErrorType (RCP)) -import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String (StrEncoding (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (TLS, closeConnection, tlsUniq) -import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) import Simplex.Messaging.Util import Simplex.RemoteControl.Client -import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..)) +import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..), RCVerifiedInvitation (..), verifySignedInvitation) import Simplex.RemoteControl.Types import System.FilePath (takeFileName, ()) import UnliftIO @@ -92,6 +90,9 @@ hostAppVersionRange = mkAppVersionRange minRemoteCtrlVersion currentAppVersion networkIOTimeout :: Int networkIOTimeout = 15000000 +discoveryTimeout :: Int +discoveryTimeout = 60000000 + -- * Desktop side getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient @@ -342,19 +343,61 @@ liftRH rhId = liftError (ChatErrorRemoteHost (RHId rhId) . RHEProtocolError) -- * Mobile side -findKnownRemoteCtrl :: ChatMonad m => m () -findKnownRemoteCtrl = undefined -- do +-- ** QR/link -- | Use provided OOB link as an annouce -connectRemoteCtrl :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) -connectRemoteCtrl signedInv@RCSignedInvitation {invitation = inv@RCInvitation {ca, app}} = handleCtrlError "connectRemoteCtrl" $ do - (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app +connectRemoteCtrlURI :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) +connectRemoteCtrlURI signedInv = handleCtrlError "connectRemoteCtrl" $ do + verifiedInv <- maybe (throwError $ ChatErrorRemoteCtrl RCEBadInvitation) pure $ verifySignedInvitation signedInv withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) + connectRemoteCtrl verifiedInv + +-- ** Multicast + +findKnownRemoteCtrl :: ChatMonad m => m () +findKnownRemoteCtrl = handleCtrlError "findKnownRemoteCtrl" $ do + knownCtrls <- withStore' getRemoteCtrls + pairings <- case nonEmpty knownCtrls of + Nothing -> throwError $ ChatErrorRemoteCtrl RCENoKnownControllers + Just ne -> pure $ fmap (\RemoteCtrl {ctrlPairing} -> ctrlPairing) ne + withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) + foundCtrl <- newEmptyTMVarIO + cmdOk <- newEmptyTMVarIO + action <- async $ handleCtrlError "findKnownRemoteCtrl.discover" $ do + atomically $ takeTMVar cmdOk + (RCCtrlPairing {ctrlFingerprint}, inv) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings + rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case + Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" + Just rc -> pure rc + atomically $ putTMVar foundCtrl (rc, inv) + toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching)} + withRemoteCtrlSession $ \case + RCSessionStarting -> Right ((), RCSessionSearching {action, foundCtrl}) + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + atomically $ putTMVar cmdOk () + +confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m (RemoteCtrlInfo, CtrlAppInfo) +confirmRemoteCtrl rcId = do + (listener, found) <- withRemoteCtrlSession $ \case + RCSessionSearching {action, foundCtrl} -> Right ((action, foundCtrl), RCSessionStarting) -- drop intermediate "Searching" state so connectRemoteCtrl can proceed + _ -> throwError $ ChatErrorRemoteCtrl RCEBadState + uninterruptibleCancel listener + (RemoteCtrl{remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found + unless (rcId == foundRcId) $ throwError $ ChatErrorRemoteCtrl RCEBadController + connectRemoteCtrl verifiedInv >>= \case + (Nothing, _) -> throwChatError $ CEInternalError "connecting with a stored ctrl" + (Just rci, appInfo) -> pure (rci, appInfo) + +-- ** Common + +connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) +connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) = handleCtrlError "connectRemoteCtrl" $ do + (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca mapM_ (validateRemoteCtrl inv) rc_ hostAppInfo <- getHostAppInfo v (rcsClient, vars) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . withAgent $ \a -> - rcConnectCtrlURI a signedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) + rcConnectCtrl a verifiedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk @@ -420,9 +463,6 @@ handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {reque attach send flush -timeoutThrow :: (MonadUnliftIO m, MonadError e m) => e -> Int -> m a -> m a -timeoutThrow e ms action = timeout ms action >>= maybe (throwError e) pure - takeRCStep :: ChatMonad m => RCStepTMVar a -> m a takeRCStep = liftEitherError (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar @@ -482,10 +522,6 @@ handleGetFile encryption User {userId} RemoteFile {userId = commandUserId, fileI encFile <- liftRC $ prepareEncryptedFile encryption (h, fileSize) reply RRFile {fileSize, fileDigest} $ sendEncryptedFile encFile -discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash (TransportHost, Word16) -> m () -discoverRemoteCtrls discovered = do - error "TODO: discoverRemoteCtrls" - listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do session <- chatReadVar remoteCtrlSession @@ -506,15 +542,6 @@ remoteCtrlInfo :: RemoteCtrl -> Maybe RemoteCtrlSessionState -> RemoteCtrlInfo remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionState = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} --- XXX: only used for multicast -confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () -confirmRemoteCtrl _rcId = do - -- TODO check it exists, check the ID is the same as in session - -- RemoteCtrlSession {confirmed} <- getRemoteCtrlSession - -- withStore' $ \db -> markRemoteCtrlResolution db rcId True - -- atomically . void $ tryPutTMVar confirmed rcId -- the remote host can now proceed with connection - undefined - -- | Take a look at emoji of tlsunique, commit pairing, and start session server verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteCtrlInfo verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemoteCtrlSession" $ do @@ -555,10 +582,11 @@ stopRemoteCtrl :: ChatMonad m => m () stopRemoteCtrl = cancelActiveRemoteCtrl False handleCtrlError :: ChatMonad m => Text -> m a -> m a -handleCtrlError name action = action `catchChatError` \e -> do - logError $ name <> " remote ctrl error: " <> tshow e - cancelActiveRemoteCtrl True - throwError e +handleCtrlError name action = + action `catchChatError` \e -> do + logError $ name <> " remote ctrl error: " <> tshow e + cancelActiveRemoteCtrl True + throwError e cancelActiveRemoteCtrl :: ChatMonad m => Bool -> m () cancelActiveRemoteCtrl handlingError = handleAny (logError . tshow) $ do @@ -570,6 +598,7 @@ cancelActiveRemoteCtrl handlingError = handleAny (logError . tshow) $ do cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () cancelRemoteCtrl handlingError = \case RCSessionStarting -> pure () + RCSessionSearching {action} -> uninterruptibleCancel action RCSessionConnecting {rcsClient, rcsWaitSession} -> do unless handlingError $ uninterruptibleCancel rcsWaitSession cancelCtrlClient rcsClient diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index a6843de601..3060723a1d 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1724,6 +1724,7 @@ viewRemoteCtrls = \case plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> maybe "" viewSessionState sessionState viewSessionState = \case RCSStarting -> " (starting)" + RCSSearching -> " (searching)" RCSConnecting -> " (connecting)" RCSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" RCSConnected _ -> " (connected)" diff --git a/stack.yaml b/stack.yaml index f3d98d7f65..fd831e8100 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: c501f4f9ccdd48807a5153697ea1827129841158 + commit: 40ba94ce72fb4273641c56fd4c60cd133a24925a - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index db19cac519..dc2f890a7f 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -36,6 +36,7 @@ remoteTests = describe "Remote" $ do it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False it "connects with new pairing (stops desktop)" $ remoteHandshakeTest True it "connects with stored pairing" remoteHandshakeStoredTest + it "connects with multicast discovery" remoteHandshakeDiscoverTest it "refuses invalid client cert" remoteHandshakeRejectTest it "sends messages" remoteMessageTest describe "remote files" $ do @@ -96,6 +97,16 @@ remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile startRemoteStored mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) +remoteHandshakeDiscoverTest :: HasCallStack => FilePath -> IO () +remoteHandshakeDiscoverTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do + logNote "Preparing new session" + startRemote mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + + logNote "Starting stored session with multicast" + startRemoteDiscover mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + remoteHandshakeRejectTest :: HasCallStack => FilePath -> IO () remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop mobileBob -> do logNote "Starting new session" @@ -420,12 +431,8 @@ startRemote mobile desktop = do mobile ##> ("/connect remote ctrl " <> inv) mobile <## "connecting new remote controller: My desktop, v5.4.0.3" desktop <## "new remote host connecting" - desktop <## "Compare session code with host:" - sessId <- getTermLine desktop mobile <## "new remote controller connected" - mobile <## "Compare session code with controller and use:" - mobile <## ("/verify remote ctrl " <> sessId) - mobile ##> ("/verify remote ctrl " <> sessId) + verifyRemoteCtrl mobile desktop mobile <## "remote controller 1 session started with My desktop" desktop <## "new remote host 1 added: Mobile" desktop <## "remote host 1 connected" @@ -439,14 +446,37 @@ startRemoteStored mobile desktop = do mobile ##> ("/connect remote ctrl " <> inv) mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" desktop <## "remote host 1 connecting" + mobile <## "remote controller 1 connected" + verifyRemoteCtrl mobile desktop + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + +startRemoteDiscover :: TestCC -> TestCC -> IO () +startRemoteDiscover mobile desktop = do + desktop ##> "/start remote host 1 multicast=on" + desktop <## "remote host 1 started" + desktop <## "Remote session invitation:" + _inv <- getTermLine desktop -- will use multicast instead + mobile ##> "/find remote ctrl" + mobile <## "ok" + mobile <## "remote controller found:" + mobile <## "1. My desktop" + mobile ##> "/confirm remote ctrl 1" + + mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + desktop <## "remote host 1 connecting" + mobile <## "remote controller 1 connected" + verifyRemoteCtrl mobile desktop + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + +verifyRemoteCtrl :: TestCC -> TestCC -> IO () +verifyRemoteCtrl mobile desktop = do desktop <## "Compare session code with host:" sessId <- getTermLine desktop - mobile <## "remote controller 1 connected" mobile <## "Compare session code with controller and use:" mobile <## ("/verify remote ctrl " <> sessId) mobile ##> ("/verify remote ctrl " <> sessId) - mobile <## "remote controller 1 session started with My desktop" - desktop <## "remote host 1 connected" contactBob :: TestCC -> TestCC -> IO () contactBob desktop bob = do From c9a1de6e4b20f3aa0898942b0f32c4d0acdbf8d7 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 18 Nov 2023 03:20:44 +0800 Subject: [PATCH 205/294] msys2 setup in different place (#3389) --- .github/workflows/build.yml | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 592e03257b..600b934bd5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -261,11 +261,27 @@ jobs: # / Windows # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing + - name: 'Setup MSYS2' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: msys2/setup-msys2@v2 + with: + msystem: ucrt64 + update: true + install: >- + git + perl + make + pacboy: >- + toolchain:p + cmake:p + + - name: Windows build id: windows_build if: matrix.os == 'windows-latest' - shell: bash + shell: msys2 {0} run: | + export PATH=$PATH:/c/ghcup/bin scripts/desktop/prepare-openssl-windows.sh openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') rm cabal.project.local 2>/dev/null || true @@ -302,20 +318,6 @@ jobs: body: | ${{ steps.windows_build.outputs.bin_hash }} - - name: 'Setup MSYS2' - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: msys2/setup-msys2@v2 - with: - msystem: ucrt64 - update: true - install: >- - git - perl - make - pacboy: >- - toolchain:p - cmake:p - - name: Windows build desktop id: windows_desktop_build if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' From 8f6a31ca0727dbb896a54e791781ffdce2903931 Mon Sep 17 00:00:00 2001 From: qvsojBJGiEnR <138215483+qvsojBJGiEnR@users.noreply.github.com> Date: Fri, 17 Nov 2023 23:29:25 +0000 Subject: [PATCH 206/294] Update app-settings.md (#3379) --- docs/guide/app-settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/app-settings.md b/docs/guide/app-settings.md index 26817a20f2..5482a1297f 100644 --- a/docs/guide/app-settings.md +++ b/docs/guide/app-settings.md @@ -8,7 +8,7 @@ title: App settings To open app settings: - Open the app. -- Tap on your user profile image in the upper right-hand of the screen. +- Tap on your user profile image in the upper left-hand of the screen. - If you have more than one profile, tap the current profile again or choose Settings. ## Your profile settings From 80abc18371feac57a68a8778390008019ce3ca16 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 15:35:06 +0000 Subject: [PATCH 207/294] core: update simplexmq (xrcp) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 4583237971..4652ee3112 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 40ba94ce72fb4273641c56fd4c60cd133a24925a + tag: 08410671323c056bbcf1a3f2756aad810b522e25 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0ccb77a743..de10eb829a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."40ba94ce72fb4273641c56fd4c60cd133a24925a" = "0vqjk4c5vd32y92myv6xr4jhipqza6n08qpii4a0xw6ssm5dgc88"; + "https://github.com/simplex-chat/simplexmq.git"."08410671323c056bbcf1a3f2756aad810b522e25" = "0vqjk4c5vd32y92myv6xr4jhipqza6n08qpii4a0xw6ssm5dgc88"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/stack.yaml b/stack.yaml index fd831e8100..3006b8a61d 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 40ba94ce72fb4273641c56fd4c60cd133a24925a + commit: 08410671323c056bbcf1a3f2756aad810b522e25 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From c0e8740f5079a9a7b713b3519fbb69d840f16ca0 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 18 Nov 2023 21:52:01 +0400 Subject: [PATCH 208/294] core: group message forwarding (#3360) * core: group message forwarding types * xgrpmemcon * rework xgrpmemcon to use intros table * only forward w/t error * forward msg * xGrpMsgForward, check integrity outside * deduplicate group messages * test * change error * item forwarded flag * intro_chat_protocol_version, bump version * comment * highly available client option * more comments * notify xgrpmemcon on deduplication * member vrange * encoding * remove MsgForward * remove import * exclude files from forwarding * refactor * rename to align with protocol * forward more message types * add events * remove unused error, function * add x.file.cancel, x.info and x.grp.mem.new to forwarded messages * remove unused x.msg.file.cancel --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 519 ++++++++++++------ src/Simplex/Chat/Controller.hs | 3 +- src/Simplex/Chat/Messages.hs | 13 +- .../Migrations/M20231113_group_forward.hs | 53 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 32 +- src/Simplex/Chat/Mobile.hs | 3 +- src/Simplex/Chat/Options.hs | 11 +- src/Simplex/Chat/Protocol.hs | 57 +- src/Simplex/Chat/Store/Connections.hs | 8 +- src/Simplex/Chat/Store/Groups.hs | 241 +++++--- src/Simplex/Chat/Store/Messages.hs | 139 +++-- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Shared.hs | 12 + src/Simplex/Chat/Types.hs | 21 +- src/Simplex/Chat/View.hs | 7 +- tests/ChatClient.hs | 3 +- tests/ChatTests/Groups.hs | 134 ++++- tests/ProtocolTests.hs | 14 +- 19 files changed, 936 insertions(+), 339 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231113_group_forward.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 622226a0c2..d379203ca3 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -120,6 +120,7 @@ library Simplex.Chat.Migrations.M20231019_indexes Simplex.Chat.Migrations.M20231030_xgrplinkmem_received Simplex.Chat.Migrations.M20231107_indexes + Simplex.Chat.Migrations.M20231113_group_forward Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 12936b325a..524fae2bd8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -32,6 +32,7 @@ import Data.Bifunctor (bimap, first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char import Data.Constraint (Dict (..)) import Data.Either (fromRight, rights) @@ -144,7 +145,8 @@ defaultChatConfig = cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes - coreApi = False + coreApi = False, + highlyAvailable = False } _defaultSMPServers :: NonEmpty SMPServerWithAuth @@ -188,9 +190,9 @@ createChatDatabase filePrefix key confirmMigrations = runExceptT $ do pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do +newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable} firstTime = dbNew chatStore currentUser <- newTVarIO user servers <- agentServers config @@ -1571,7 +1573,7 @@ processChatCommand = \case gVar <- asks idsDrg subMode <- chatReadVar subscriptionMode (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode - member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq subMode + member <- withStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} @@ -3227,7 +3229,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do MSG meta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId meta $ do - (_conn', _) <- saveRcvMSG conn (ConnectionId connId) meta msgBody cmdId + (_conn', _) <- saveDirectRcvMSG conn meta cmdId msgBody pure False SENT msgId -> sentMsgDeliveryEvent conn msgId @@ -3258,14 +3260,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do - (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody cmdId + (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn msgMeta cmdId msgBody let ct' = ct {activeConn = Just conn'} :: Contact assertDirectAllowed user MDRcv ct' $ toCMEventTag event updateChatLock "directMessage" event case event of XMsgNew mc -> newContentMessage ct' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct' sharedMsgId fileDescr msgMeta - XMsgFileCancel sharedMsgId -> cancelMessageFile ct' sharedMsgId msgMeta XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct' sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId _ -> messageDelete ct' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct' sharedMsgId reaction add msg msgMeta @@ -3342,10 +3343,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) forM_ groupId_ $ \groupId -> do + groupInfo <- withStore $ \db -> getGroupInfo db user groupId subMode <- chatReadVar subscriptionMode - gVar <- asks idsDrg groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode - withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode + gVar <- asks idsDrg + withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode _ -> pure () Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do @@ -3515,62 +3517,118 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> updateIntroStatus db introId GMIntroSent _ -> do -- TODO notify member who forwarded introduction - question - where it is stored? There is via_contact but probably there should be via_member in group_members table + let memCategory = memberCategory m withStore' (\db -> getViaGroupContact db user m) >>= \case Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership - when (memberCategory m == GCPreMember) $ probeMatchingMemberContact m connectedIncognito + when (memCategory == GCPreMember) $ probeMatchingMemberContact m connectedIncognito Just ct@Contact {activeConn} -> forM_ activeConn $ \Connection {connStatus} -> when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True + when (memCategory == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True + sendXGrpMemCon memCategory + where + sendXGrpMemCon = \case + GCPreMember -> + forM_ (invitedByGroupMemberId membership) $ \hostId -> do + host <- withStore $ \db -> getGroupMember db user groupId hostId + forM_ (memberConn host) $ \hostConn -> + void $ sendDirectMessage hostConn (XGrpMemCon m.memberId) (GroupId groupId) + GCPostMember -> + forM_ (invitedByGroupMemberId m) $ \invitingMemberId -> do + im <- withStore $ \db -> getGroupMember db user groupId invitingMemberId + forM_ (memberConn im) $ \imConn -> + void $ sendDirectMessage imConn (XGrpMemCon m.memberId) (GroupId groupId) + _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn - withAckMessage agentConnId cmdId msgMeta $ do - (conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody cmdId - let m' = m {activeConn = Just conn'} :: GroupMember - updateChatLock "groupMessage" event - case event of - XMsgNew mc -> canSend m' $ newGroupContentMessage gInfo m' mc msg msgMeta - XMsgFileDescr sharedMsgId fileDescr -> canSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr msgMeta - XMsgFileCancel sharedMsgId -> cancelGroupMessageFile gInfo m' sharedMsgId msgMeta - XMsgUpdate sharedMsgId mContent ttl live -> canSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg msgMeta ttl live - XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg msgMeta - XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m' sharedMsgId memberId reaction add msg msgMeta - -- TODO discontinue XFile - XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg msgMeta - XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId msgMeta - XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName msgMeta - -- XInfo p -> xInfoMember gInfo m' p -- TODO use for member profile update - XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p - XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg msgMeta - XGrpMemIntro memInfo -> xGrpMemIntro gInfo m' memInfo - XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv - XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo m' memInfo introInv - XGrpMemRole memId memRole -> xGrpMemRole gInfo m' memId memRole msg msgMeta - XGrpMemDel memId -> xGrpMemDel gInfo m' memId msg msgMeta - XGrpLeave -> xGrpLeave gInfo m' msg msgMeta - XGrpDel -> xGrpDel gInfo m' msg msgMeta - XGrpInfo p' -> xGrpInfo gInfo m' p' msg msgMeta - XGrpDirectInv connReq mContent_ -> canSend m' $ xGrpDirectInv gInfo m' conn' connReq mContent_ msg msgMeta - XInfoProbe probe -> xInfoProbe (COMGroupMember m') probe - XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m') probeHash - XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe - BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta - _ -> messageError $ "unsupported message: " <> T.pack (show event) - currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo - pure $ - fromMaybe (sendRcptsSmallGroups user) sendRcpts - && hasDeliveryReceipt (toCMEventTag event) - && currentMemCount <= smallGroupsRcptsMemLimit + tryChatError (processChatMessage cmdId) >>= \case + Right (ACMsg _ chatMsg, withRcpt) -> do + ackMsg agentConnId cmdId msgMeta $ if withRcpt then Just "" else Nothing + when (membership.memberRole >= GRAdmin) $ forwardMsg_ chatMsg + Left e -> ackMsg agentConnId cmdId msgMeta Nothing >> throwError e where - canSend :: GroupMember -> m () -> m () - canSend mem a - | mem.memberRole <= GRObserver = messageError "member is not allowed to send messages" - | otherwise = a + processChatMessage :: Int64 -> m (AChatMessage, Bool) + processChatMessage cmdId = do + msg@(ACMsg _ chatMsg) <- parseAChatMessage conn msgMeta msgBody + checkIntegrity chatMsg `catchChatError` \_ -> pure () + (msg,) <$> processEvent cmdId chatMsg + brokerTs = metaBrokerTs msgMeta + checkIntegrity :: ChatMessage e -> m () + checkIntegrity ChatMessage {chatMsgEvent} = do + when checkForEvent $ checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta + where + checkForEvent = case chatMsgEvent of + XMsgNew _ -> True + XFileCancel _ -> True + XFileAcptInv {} -> True + XGrpMemNew _ -> True + XGrpMemRole {} -> True + XGrpMemDel _ -> True + XGrpLeave -> True + XGrpDel -> True + XGrpInfo _ -> True + XGrpDirectInv {} -> True + _ -> False + processEvent :: MsgEncodingI e => CommandId -> ChatMessage e -> m Bool + processEvent cmdId chatMsg = do + (m', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m conn msgMeta cmdId msgBody chatMsg + updateChatLock "groupMessage" event + case event of + XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs + XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg brokerTs ttl live + XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg brokerTs + XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m' sharedMsgId memberId reaction add msg brokerTs + -- TODO discontinue XFile + XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg brokerTs + XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId + XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName + XInfo p -> xInfoMember gInfo m' p + XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p + XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg brokerTs + XGrpMemIntro memInfo -> xGrpMemIntro gInfo m' memInfo + XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv + XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo m' memInfo introInv + XGrpMemRole memId memRole -> xGrpMemRole gInfo m' memId memRole msg brokerTs + XGrpMemCon memId -> xGrpMemCon gInfo m' memId + XGrpMemDel memId -> xGrpMemDel gInfo m' memId msg brokerTs + XGrpLeave -> xGrpLeave gInfo m' msg brokerTs + XGrpDel -> xGrpDel gInfo m' msg brokerTs + XGrpInfo p' -> xGrpInfo gInfo m' p' msg brokerTs + XGrpDirectInv connReq mContent_ -> memberCanSend m' $ xGrpDirectInv gInfo m' conn' connReq mContent_ msg brokerTs + XGrpMsgForward memberId msg' msgTs -> xGrpMsgForward gInfo m' memberId msg' msgTs + XInfoProbe probe -> xInfoProbe (COMGroupMember m') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe + BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta + _ -> messageError $ "unsupported message: " <> T.pack (show event) + checkSendRcpt event + checkSendRcpt :: ChatMsgEvent e -> m Bool + checkSendRcpt event = do + currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo + let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo + pure $ + fromMaybe (sendRcptsSmallGroups user) sendRcpts + && hasDeliveryReceipt (toCMEventTag event) + && currentMemCount <= smallGroupsRcptsMemLimit + forwardMsg_ :: MsgEncodingI e => ChatMessage e -> m () + forwardMsg_ chatMsg = + forM_ (forwardedGroupMsg chatMsg) $ \chatMsg' -> do + ChatConfig {highlyAvailable} <- asks config + -- members introduced to this invited member + introducedMembers <- if memberCategory m == GCInviteeMember + then withStore' $ \db -> getForwardIntroducedMembers db user m highlyAvailable + else pure [] + -- invited members to which this member was introduced + invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db user m highlyAvailable + let ms = introducedMembers <> invitedMembers + msg = XGrpMsgForward m.memberId chatMsg' brokerTs + unless (null ms) $ + void $ sendGroupMessage user gInfo ms msg RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ groupMsgReceived gInfo m conn msgMeta msgRcpt @@ -3829,6 +3887,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> toView $ CRReceivedContactRequest user cReq _ -> pure () + memberCanSend :: GroupMember -> m () -> m () + memberCanSend mem a + | mem.memberRole <= GRObserver = messageError "member is not allowed to send messages" + | otherwise = a + incAuthErrCounter :: ConnectionEntity -> Connection -> AgentErrorType -> m () incAuthErrCounter connEntity conn err = do case err of @@ -3872,7 +3935,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withAckMessage cId cmdId msgMeta $ action $> False withAckMessage :: ConnId -> CommandId -> MsgMeta -> m Bool -> m () - withAckMessage cId cmdId MsgMeta {recipient = (msgId, _)} action = do + withAckMessage cId cmdId msgMeta action = do -- [async agent commands] command should be asynchronous, continuation is ackMsgDeliveryEvent -- TODO catching error and sending ACK after an error, particularly if it is a database error, will result in the message not processed (and no notification to the user). -- Possible solutions are: @@ -3880,10 +3943,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- 2) stabilize database -- 3) show screen of death to the user asking to restart tryChatError action >>= \case - Right withRcpt -> ack $ if withRcpt then Just "" else Nothing - Left e -> ack Nothing >> throwError e - where - ack rcpt = withAgent $ \a -> ackMessageAsync a (aCorrId cmdId) cId msgId rcpt + Right withRcpt -> ackMsg cId cmdId msgMeta $ if withRcpt then Just "" else Nothing + Left e -> ackMsg cId cmdId msgMeta Nothing >> throwError e + + ackMsg :: ConnId -> CommandId -> MsgMeta -> Maybe MsgReceiptInfo -> m () + ackMsg cId cmdId MsgMeta {recipient = (msgId, _)} rcpt = withAgent $ \a -> ackMessageAsync a (aCorrId cmdId) cId msgId rcpt ackMsgDeliveryEvent :: Connection -> CommandId -> m () ackMsgDeliveryEvent Connection {connId} ackCmdId = @@ -4003,8 +4067,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live autoAcceptFile file_ where + brokerTs = metaBrokerTs msgMeta newChatItem ciContent ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}) @@ -4019,8 +4084,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId processFDMessage fileId fileDescr - groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> MsgMeta -> m () - groupMessageFileDescription GroupInfo {groupId} _m sharedMsgId fileDescr _msgMeta = do + groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> m () + groupMessageFileDescription GroupInfo {groupId} _m sharedMsgId fileDescr = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId processFDMessage fileId fileDescr @@ -4038,17 +4103,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (RFSAccepted _, Just XFTPRcvFile {}) -> receiveViaCompleteFD user fileId rfd cryptoArgs _ -> pure () - cancelMessageFile :: Contact -> SharedMsgId -> MsgMeta -> m () - cancelMessageFile ct _sharedMsgId msgMeta = do - checkIntegrityCreateItem (CDDirectRcv ct) msgMeta - -- find the original chat item and file - -- mark file as cancelled, remove description if exists - pure () - - cancelGroupMessageFile :: GroupInfo -> GroupMember -> SharedMsgId -> MsgMeta -> m () - cancelGroupMessageFile _gInfo _m _sharedMsgId _msgMeta = do - pure () - processFileInvitation :: Maybe FileInvitation -> MsgContent -> (DB.Connection -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer) -> m (Maybe (RcvFileTransfer, CIFile 'MDRcv)) processFileInvitation fInv_ mc createRcvFT = forM fInv_ $ \fInv@FileInvitation {fileName, fileSize} -> do ChatConfig {fileChunkSize} <- asks config @@ -4075,13 +4129,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvContactCITimed ct ttl - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta content Nothing timed_ live + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs content Nothing timed_ live ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content live Nothing toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') where - MsgMeta {broker = (_, brokerTs)} = msgMeta + brokerTs = metaBrokerTs msgMeta content = CIRcvMsgContent mc live = fromMaybe False live_ updateRcvChatItem = do @@ -4136,8 +4190,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do else pure Nothing mapM_ toView cr_ - groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> MsgReaction -> Bool -> RcvMessage -> MsgMeta -> m () - groupMsgReaction g@GroupInfo {groupId} m sharedMsgId itemMemberId reaction add RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do + groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> m () + groupMsgReaction g@GroupInfo {groupId} m sharedMsgId itemMemberId reaction add RcvMessage {msgId} brokerTs = do when (groupFeatureAllowed SGFReactions g) $ do rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False when (reactionAllowed add reaction rs) $ do @@ -4166,8 +4220,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e - newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> m () + newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles | otherwise = do @@ -4187,38 +4241,37 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | moderatorRole < GRAdmin || moderatorRole < memberRole = createItem timed_ live | groupFeatureAllowed SGFFullDelete gInfo = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta CIRcvModerated Nothing timed_ False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed_ False ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt toView $ CRNewChatItem user $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci' | otherwise = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent content) (snd <$> file_) timed_ False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent content) (snd <$> file_) timed_ False toView =<< markGroupCIDeleted user gInfo ci createdByMsgId False (Just moderator) moderatedAt createItem timed_ live = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live when (showMessages $ memberSettings m) $ autoAcceptFile file_ newChatItem ciContent ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ - groupMsgToView gInfo m ci' {reactions} msgMeta + groupMsgToView gInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () - groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = + groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> m () + groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} brokerTs ttl_ live_ = updateRcvChatItem `catchCINotFound` \_ -> do -- This patches initial sharedMsgId into chat item when locally deleted chat item -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) brokerTs content Nothing timed_ live ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc ci' <- updateGroupChatItem db user groupId ci content live Nothing blockedMember m ci' $ markGroupChatItemBlocked db user gInfo ci' toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') where - MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc live = fromMaybe False live_ updateRcvChatItem = do @@ -4241,8 +4294,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do else messageError "x.msg.update: group member attempted to update a message of another member" _ -> messageError "x.msg.update: group member attempted invalid message update" - groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> MsgMeta -> m () - groupMessageDelete gInfo@GroupInfo {groupId, membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do + groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> UTCTime -> m () + groupMessageDelete gInfo@GroupInfo {groupId, membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} brokerTs = do let msgMemberId = fromMaybe memberId sndMemberId_ withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user groupId msgMemberId sharedMsgId) >>= \case Right (CChatItem _ ci@ChatItem {chatDir}) -> case chatDir of @@ -4279,20 +4332,22 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + where + brokerTs = metaBrokerTs msgMeta -- TODO remove once XFile is discontinued - processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do + processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> UTCTime -> m () + processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} brokerTs = do ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci - groupMsgToView gInfo m ci' msgMeta + groupMsgToView gInfo ci' blockedMember :: Monad m' => GroupMember -> ChatItem c d -> m' (ChatItem c d) -> m' (ChatItem c d) blockedMember m ci blockedCI @@ -4399,9 +4454,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> pure () receiveFileChunk ft Nothing meta chunk - xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> MsgMeta -> m () - xFileCancelGroup g@GroupInfo {groupId} mem@GroupMember {groupMemberId, memberId} sharedMsgId msgMeta = do - checkIntegrityCreateItem (CDGroupRcv g mem) msgMeta + xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> m () + xFileCancelGroup GroupInfo {groupId} GroupMember {groupMemberId, memberId} sharedMsgId = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId case (msgDir, chatDir) of @@ -4416,9 +4470,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" - xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe ConnReqInvitation -> String -> MsgMeta -> m () - xFileAcptInvGroup g@GroupInfo {groupId} m@GroupMember {activeConn} sharedMsgId fileConnReq_ fName msgMeta = do - checkIntegrityCreateItem (CDGroupRcv g m) msgMeta + xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe ConnReqInvitation -> String -> m () + xFileAcptInvGroup GroupInfo {groupId} m@GroupMember {activeConn} sharedMsgId fileConnReq_ fName = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId (AChatItem _ _ _ ci) <- withStore $ \db -> getChatItemByFileId db user fileId assertSMPAcceptNotProhibited ci @@ -4447,9 +4500,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "x.file.acpt.inv: member connection is not active" else messageError "x.file.acpt.inv: fileName is different from expected" - groupMsgToView :: GroupInfo -> GroupMember -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () - groupMsgToView gInfo m ci msgMeta = do - checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta + groupMsgToView :: GroupInfo -> ChatItem 'CTGroup 'MDRcv -> m () + groupMsgToView gInfo ci = toView $ CRNewChatItem user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () @@ -4475,11 +4527,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) else do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content + ci <- saveRcvChatItem user (CDDirectRcv ct) msg brokerTs content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where + brokerTs = metaBrokerTs msgMeta sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool sameGroupLinkId (Just gli) (Just gli') = gli == gli' sameGroupLinkId _ _ = False @@ -4503,13 +4556,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} let ct'' = ct' {activeConn = activeConn'} :: Contact - ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) + ci <- saveRcvChatItem user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) toView $ CRContactDeletedByContact user ct'' else do contactConns <- withStore' $ \db -> getContactConnections db userId c deleteAgentConnectionsAsync user $ map aConnId contactConns withStore' $ \db -> deleteContact db user c + where + brokerTs = metaBrokerTs msgMeta processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact processContactProfileUpdate c@Contact {profile = p} p' createItems @@ -4540,9 +4595,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | otherwise -> Nothing in setPreference_ SCFTimedMessages ctUserTMPref' ctUserPrefs - -- TODO use for member profile update - -- xInfoMember :: GroupInfo -> GroupMember -> Profile -> m () - -- xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' + xInfoMember :: GroupInfo -> GroupMember -> Profile -> m () + xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> m () xGrpLinkMem gInfo@GroupInfo {membership} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do @@ -4674,9 +4728,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRNewChatItem user $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci else featureRejected CFCalls where - saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) + brokerTs = metaBrokerTs msgMeta + saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) featureRejected f = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvChatFeatureRejected f) Nothing Nothing False + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvChatFeatureRejected f) Nothing Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) -- to party initiating call @@ -4835,21 +4890,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO show/log error, other events in SMP confirmation _ -> pure conn' - xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> MsgMeta -> m () - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg msgMeta = do + xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> m () + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg brokerTs = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo unless (sameMemberId memId $ membership gInfo) $ if isMember memId gInfo members then messageError "x.grp.mem.new error: member already exists" else do - newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) - groupMsgToView gInfo m ci msgMeta + newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) + groupMsgToView gInfo ci toView $ CRJoinedGroupMemberConnecting user gInfo m newMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () - xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do + xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) = do case memberCategory m of GCHostMember -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -4860,7 +4915,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do subMode <- chatReadVar subscriptionMode -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second groupConnIds <- createConn subMode - directConnIds <- case memberChatVRange of + directConnIds <- case memChatVRange of Nothing -> Just <$> createConn subMode Just mcvr | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> pure Nothing @@ -4892,7 +4947,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo toMember <- case find (sameMemberId memId) members of @@ -4900,7 +4955,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- the situation when member does not exist is an error -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. -- For now, this branch compensates for the lack of delayed message delivery. - Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced + Nothing -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced Just m' -> pure m' withStore' $ \db -> saveMemberInvitation db toMember introInv subMode <- chatReadVar subscriptionMode @@ -4910,11 +4965,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange + mcvr = maybe chatInitialVRange fromChatVRange memChatVRange withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId subMode - xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> MsgMeta -> m () - xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg msgMeta + xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> m () + xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg brokerTs | membership.memberId == memId = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole @@ -4928,16 +4983,54 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent gEvent) - groupMsgToView gInfo m ci msgMeta + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView gInfo ci toView CRMemberRole {user, groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} checkHostRole :: GroupMember -> GroupMemberRole -> m () checkHostRole GroupMember {memberRole, localDisplayName} memRole = when (memberRole < GRAdmin || memberRole < memRole) $ throwChatError (CEGroupContactRole localDisplayName) - xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> MsgMeta -> m () - xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId msg msgMeta = do + xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> m () + xGrpMemCon gInfo sendingMember memId = do + refMember <- withStore $ \db -> getGroupMemberByMemberId db user gInfo memId + case (memberCategory sendingMember, memberCategory refMember) of + (GCInviteeMember, GCInviteeMember) -> + withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case + Right intro -> inviteeXGrpMemCon intro + Left _ -> withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case + Right intro -> forwardMemberXGrpMemCon intro + Left _ -> messageWarning "x.grp.mem.con: no introduction" + (GCInviteeMember, _) -> + withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case + Right intro -> inviteeXGrpMemCon intro + Left _ -> messageWarning "x.grp.mem.con: no introduction" + (_, GCInviteeMember) -> + withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case + Right intro -> forwardMemberXGrpMemCon intro + Left _ -> messageWarning "x.grp.mem.con: no introductiosupportn" + -- Note: we can allow XGrpMemCon to all member categories if we decide to support broader group forwarding, + -- deduplication (see saveGroupRcvMsg, saveGroupFwdRcvMsg) already supports sending XGrpMemCon + -- to any forwarding member, not only host/inviting member; + -- database would track all members connections then + -- (currently it's done via group_member_intros for introduced connections only) + _ -> + messageWarning "x.grp.mem.con: neither member is invitee" + where + inviteeXGrpMemCon :: GroupMemberIntro -> m () + inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} + | introStatus == GMIntroReConnected = updateStatus introId GMIntroConnected + | introStatus `elem` [GMIntroToConnected, GMIntroConnected] = pure () + | otherwise = updateStatus introId GMIntroToConnected + forwardMemberXGrpMemCon :: GroupMemberIntro -> m () + forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} + | introStatus == GMIntroToConnected = updateStatus introId GMIntroConnected + | introStatus `elem` [GMIntroReConnected, GMIntroConnected] = pure () + | otherwise = updateStatus introId GMIntroReConnected + updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status + + xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> m () + xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId msg brokerTs = do members <- withStore' $ \db -> getGroupMembers db user gInfo if membership.memberId == memId then checkRole membership $ do @@ -4963,23 +5056,20 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageError "x.grp.mem.del with insufficient member permissions" | otherwise = a deleteMemberItem gEvent = do - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent gEvent) - groupMsgToView gInfo m ci msgMeta + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView gInfo ci - sameMemberId :: MemberId -> GroupMember -> Bool - sameMemberId memId GroupMember {memberId} = memId == memberId - - xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> MsgMeta -> m () - xGrpLeave gInfo m msg msgMeta = do + xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> m () + xGrpLeave gInfo m msg brokerTs = do deleteMemberConnection user m -- member record is not deleted to allow creation of "member left" chat item withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEMemberLeft) - groupMsgToView gInfo m ci msgMeta + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + groupMsgToView gInfo ci toView $ CRLeftMember user gInfo m {memberStatus = GSMemLeft} - xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> MsgMeta -> m () - xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg msgMeta = do + xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> m () + xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do when (memberRole /= GROwner) $ throwChatError $ CEGroupUserRole gInfo GROwner ms <- withStore' $ \db -> do members <- getGroupMembers db user gInfo @@ -4987,24 +5077,24 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do pure members -- member records are not deleted to keep history deleteMembersConnections user ms - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEGroupDeleted) - groupMsgToView gInfo m ci msgMeta + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) + groupMsgToView gInfo ci toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m - xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> MsgMeta -> m () - xGrpInfo g@GroupInfo {groupProfile = p} m@GroupMember {memberRole} p' msg msgMeta + xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> m () + xGrpInfo g@GroupInfo {groupProfile = p} m@GroupMember {memberRole} p' msg brokerTs | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" | otherwise = unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' toView $ CRGroupUpdated user g g' (Just m) let cd = CDGroupRcv g' m unless (sameGroupProfileInfo p p') $ do - ci <- saveRcvChatItem user cd msg msgMeta (CIRcvGroupEvent $ RGEGroupUpdated p') - groupMsgToView g' m ci msgMeta + ci <- saveRcvChatItem user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') + groupMsgToView g' ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' - xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> MsgMeta -> m () - xGrpDirectInv g m mConn connReq mContent_ msg msgMeta = do + xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> UTCTime -> m () + xGrpDirectInv g m mConn connReq mContent_ msg brokerTs = do unless (groupFeatureAllowed SGFDirectMessages g) $ messageError "x.grp.direct.inv: direct messages not allowed" let GroupMember {memberContactId} = m subMode <- chatReadVar subscriptionMode @@ -5040,11 +5130,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do dm <- directMessage $ XInfo p joinAgentConnectionAsync user True connReq dm subMode createItems mCt' m' = do - checkIntegrityCreateItem (CDGroupRcv g m') msgMeta createInternalChatItem user (CDGroupRcv g m') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing toView $ CRNewMemberContactReceivedInv user mCt' g m' forM_ mContent_ $ \mc -> do - ci <- saveRcvChatItem user (CDDirectRcv mCt') msg msgMeta (CIRcvMsgContent mc) + ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat mCt') ci) securityCodeChanged :: Contact -> m () @@ -5052,6 +5141,33 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing + xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> ChatMessage 'Json -> UTCTime -> m () + xGrpMsgForward gInfo@GroupInfo {groupId} m memberId msg msgTs = do + when (m.memberRole < GRAdmin) $ throwChatError (CEGroupContactRole m.localDisplayName) + author <- withStore $ \db -> getGroupMemberByMemberId db user gInfo memberId + processForwardedMsg author msg + where + -- Note: forwarded group events (see forwardedGroupMsg) should include msgId to be deduplicated + processForwardedMsg :: GroupMember -> ChatMessage 'Json -> m () + processForwardedMsg author chatMsg = do + let body = LB.toStrict $ J.encode msg + rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} <- saveGroupFwdRcvMsg user groupId m author body chatMsg + case event of + XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs + XMsgFileDescr sharedMsgId fileDescr -> memberCanSend author $ groupMessageFileDescription gInfo author sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent rcvMsg msgTs ttl live + XMsgDel sharedMsgId memId -> groupMessageDelete gInfo author sharedMsgId memId rcvMsg msgTs + XMsgReact sharedMsgId (Just memId) reaction add -> groupMsgReaction gInfo author sharedMsgId memId reaction add rcvMsg msgTs + XFileCancel sharedMsgId -> xFileCancelGroup gInfo author sharedMsgId + XInfo p -> xInfoMember gInfo author p + XGrpMemNew memInfo -> xGrpMemNew gInfo author memInfo rcvMsg msgTs + XGrpMemRole memId memRole -> xGrpMemRole gInfo author memId memRole rcvMsg msgTs + XGrpMemDel memId -> xGrpMemDel gInfo author memId rcvMsg msgTs + XGrpLeave -> xGrpLeave gInfo author rcvMsg msgTs + XGrpDel -> xGrpDel gInfo author rcvMsg msgTs + XGrpInfo p' -> xGrpInfo gInfo author p' rcvMsg msgTs + _ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event) + directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta @@ -5100,6 +5216,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () +metaBrokerTs :: MsgMeta -> UTCTime +metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs + +sameMemberId :: MemberId -> GroupMember -> Bool +sameMemberId memId GroupMember {memberId} = memId == memberId + updatePeerChatVRange :: ChatMonad m => Connection -> VersionRange -> m Connection updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do let jMsgChatVRange = JVersionRange msgChatVRange @@ -5109,6 +5231,18 @@ updatePeerChatVRange conn@Connection {connId, peerChatVRange} msgChatVRange = do pure conn {peerChatVRange = jMsgChatVRange} else pure conn +updateMemberChatVRange :: ChatMonad m => GroupMember -> Connection -> VersionRange -> m (GroupMember, Connection) +updateMemberChatVRange mem@GroupMember {groupMemberId} conn@Connection {connId, peerChatVRange} msgChatVRange = do + let jMsgChatVRange = JVersionRange msgChatVRange + if jMsgChatVRange /= peerChatVRange + then do + withStore' $ \db -> do + setPeerChatVRange db connId msgChatVRange + setMemberChatVRange db groupMemberId msgChatVRange + let conn' = conn {peerChatVRange = jMsgChatVRange} + pure (mem {memberChatVRange = jMsgChatVRange, activeConn = Just conn'}, conn') + else pure (mem, conn) + parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) @@ -5357,18 +5491,36 @@ sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do where messageMember :: GroupMember -> SndMessage -> m (Maybe GroupMember) messageMember m@GroupMember {groupMemberId} SndMessage {msgId, msgBody} = case memberConn m of - Nothing -> do - withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ - pure $ Just m + Nothing -> pendingOrForwarded Just conn@Connection {connStatus} | connDisabled conn || connStatus == ConnDeleted -> pure Nothing | connStatus == ConnSndReady || connStatus == ConnReady -> do let tag = toCMEventTag chatMsgEvent deliverMessage conn tag msgBody msgId >> postDeliver pure $ Just m - | otherwise -> do - withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ - pure $ Just m + | otherwise -> pendingOrForwarded + where + pendingOrForwarded + | forwardSupported && isForwardedGroupMsg chatMsgEvent = pure Nothing + | isXGrpMsgForward chatMsgEvent = pure Nothing + | otherwise = do + withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + pure $ Just m + forwardSupported = do + let mcvr = memberChatVRange' m + isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward + invitingMemberSupportsForward = case m.invitedByGroupMemberId of + Just invMemberId -> + -- can be optimized for large groups by replacing [GroupMember] with Map GroupMemberId GroupMember + case find (\m' -> groupMemberId' m' == invMemberId) members of + Just invitingMember -> do + let mcvr = memberChatVRange' invitingMember + isCompatibleRange mcvr groupForwardVRange + Nothing -> False + Nothing -> False + isXGrpMsgForward ev = case ev of + XGrpMsgForward {} -> True + _ -> False sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn = do @@ -5386,18 +5538,49 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn _ -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName _ -> pure () -saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> CommandId -> m (Connection, RcvMessage) -saveRcvMSG conn@Connection {connId} connOrGroupId agentMsgMeta msgBody agentAckCmdId = do +saveDirectRcvMSG :: ChatMonad m => Connection -> MsgMeta -> CommandId -> MsgBody -> m (Connection, RcvMessage) +saveDirectRcvMSG conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody = do ACMsg _ ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} <- parseAChatMessage conn agentMsgMeta msgBody conn' <- updatePeerChatVRange conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta newMsg = NewMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} - msg <- withStoreCtx' - (Just $ "createNewMessageAndRcvMsgDelivery, rcvMsgDelivery: " <> show rcvMsgDelivery <> ", sharedMsgId_: " <> show sharedMsgId_ <> ", msgDeliveryStatus: MDSRcvAgent") - $ \db -> createNewMessageAndRcvMsgDelivery db connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery + msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing pure (conn', msg) +saveGroupRcvMsg :: (MsgEncodingI e, ChatMonad m) => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> CommandId -> MsgBody -> ChatMessage e -> m (GroupMember, Connection, RcvMessage) +saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do + (am', conn') <- updateMemberChatVRange authorMember conn chatVRange + let agentMsgId = fst $ recipient agentMsgMeta + newMsg = NewMessage {chatMsgEvent, msgBody} + rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} + amId = Just am'.groupMemberId + msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery amId) + `catchChatError` \e -> case e of + ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do + fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId + forM_ (memberConn fm) $ \fmConn -> + void $ sendDirectMessage fmConn (XGrpMemCon am'.memberId) (GroupId groupId) + throwError e + _ -> throwError e + pure (am', conn', msg) + +saveGroupFwdRcvMsg :: (MsgEncodingI e, ChatMonad m) => User -> GroupId -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> m RcvMessage +saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} = do + let newMsg = NewMessage {chatMsgEvent, msgBody} + fwdMemberId = Just $ groupMemberId' forwardingMember + refAuthorId = Just $ groupMemberId' refAuthorMember + withStore (\db -> createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) + `catchChatError` \e -> case e of + ChatErrorStore (SEDuplicateGroupMessage _ _ (Just authorGroupMemberId) Nothing) -> do + am <- withStore $ \db -> getGroupMember db user groupId authorGroupMemberId + if sameMemberId refAuthorMember.memberId am + then forM_ (memberConn forwardingMember) $ \fmConn -> + void $ sendDirectMessage fmConn (XGrpMemCon am.memberId) (GroupId groupId) + else toView $ CRMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" + throwError e + _ -> throwError e + saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> m (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing False @@ -5409,27 +5592,27 @@ saveSndChatItem' user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem ciId <- createNewSndChatItem db user cd msg content quotedItem itemTimed live createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure ciId - liftIO $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemTimed live createdAt createdAt + liftIO $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemTimed live createdAt Nothing createdAt -saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv) -saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} msgMeta content = - saveRcvChatItem' user cd msg sharedMsgId_ msgMeta content Nothing Nothing False +saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv) +saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = + saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False -saveRcvChatItem' :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> m (ChatItem c 'MDRcv) -saveRcvChatItem' user cd msg sharedMsgId_ MsgMeta {broker = (_, brokerTs)} content ciFile itemTimed live = do +saveRcvChatItem' :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> m (ChatItem c 'MDRcv) +saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content ciFile itemTimed live = do createdAt <- liftIO getCurrentTime (ciId, quotedItem) <- withStore' $ \db -> do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure (ciId, quotedItem) - liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs createdAt + liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs msg.forwardedByGroupMemberId createdAt -mkChatItem :: forall c d. MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CITimed -> Bool -> ChatItemTs -> UTCTime -> IO (ChatItem c d) -mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs currentTs = do +mkChatItem :: forall c d. MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> IO (ChatItem c d) +mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs forwardedByGroupMemberId currentTs = do let itemText = ciContentToText content itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByGroupMemberId currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} deleteDirectCI :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> m ChatResponse @@ -5592,7 +5775,7 @@ createInternalChatItem user cd content itemTs_ = do ciId <- withStore' $ \db -> do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt createNewChatItemNoMsg db user cd content itemTs createdAt - ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing Nothing False itemTs createdAt + ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing Nothing False itemTs Nothing createdAt toView $ CRNewChatItem user (AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci) getCreateActiveUser :: SQLiteStore -> Bool -> IO User diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d2e81f96f5..7c67cd9e52 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -126,7 +126,8 @@ data ChatConfig = ChatConfig cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64, -- microseconds - coreApi :: Bool + coreApi :: Bool, + highlyAvailable :: Bool } data DefaultAgentServers = DefaultAgentServers diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 2ddb1e7bcd..d35ed88185 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -162,7 +162,7 @@ isMention ChatItem {chatDir, quotedItem} = case chatDir of CIQDirectSnd -> True CIQGroupSnd -> True _ -> False - + data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectSnd :: CIDirection 'CTDirect 'MDSnd CIDirectRcv :: CIDirection 'CTDirect 'MDRcv @@ -341,17 +341,18 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, editable :: Bool, + forwardedByGroupMemberId :: Maybe GroupMemberId, createdAt :: UTCTime, updatedAt :: UTCTime } deriving (Show, Generic) -mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs createdAt updatedAt = +mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByGroupMemberId createdAt updatedAt = let editable = case itemContent of CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay && isNothing itemDeleted _ -> False - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByGroupMemberId, createdAt, updatedAt} instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions @@ -814,7 +815,9 @@ data RcvMessage = RcvMessage { msgId :: MessageId, chatMsgEvent :: AChatMsgEvent, sharedMsgId_ :: Maybe SharedMsgId, - msgBody :: MsgBody + msgBody :: MsgBody, + authorGroupMemberId :: Maybe GroupMemberId, + forwardedByGroupMemberId :: Maybe GroupMemberId } data PendingGroupMessage = PendingGroupMessage diff --git a/src/Simplex/Chat/Migrations/M20231113_group_forward.hs b/src/Simplex/Chat/Migrations/M20231113_group_forward.hs new file mode 100644 index 0000000000..f23387f011 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231113_group_forward.hs @@ -0,0 +1,53 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231113_group_forward where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231113_group_forward :: Query +m20231113_group_forward = + [sql| +ALTER TABLE group_member_intros ADD COLUMN intro_chat_protocol_version INTEGER NOT NULL DEFAULT 3; +CREATE INDEX idx_group_member_intros_re_group_member_id ON group_member_intros(re_group_member_id); + +ALTER TABLE group_members ADD COLUMN invited_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL; +ALTER TABLE group_members ADD COLUMN peer_chat_min_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE group_members ADD COLUMN peer_chat_max_version INTEGER NOT NULL DEFAULT 1; +CREATE INDEX idx_group_members_invited_by_group_member_id ON group_members(invited_by_group_member_id); + +UPDATE group_members +SET (peer_chat_min_version, peer_chat_max_version) = (c.peer_chat_min_version, c.peer_chat_max_version) +FROM connections c +WHERE c.group_member_id = group_members.group_member_id; + +ALTER TABLE messages ADD COLUMN author_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL; +ALTER TABLE messages ADD COLUMN forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL; +CREATE INDEX idx_messages_author_group_member_id ON messages(author_group_member_id); +CREATE INDEX idx_messages_forwarded_by_group_member_id ON messages(forwarded_by_group_member_id); +CREATE INDEX idx_messages_group_id_shared_msg_id ON messages(group_id, shared_msg_id); + +ALTER TABLE chat_items ADD COLUMN forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL; +CREATE INDEX idx_chat_items_forwarded_by_group_member_id ON chat_items(forwarded_by_group_member_id); +|] + +down_m20231113_group_forward :: Query +down_m20231113_group_forward = + [sql| +DROP INDEX idx_chat_items_forwarded_by_group_member_id; +ALTER TABLE chat_items DROP COLUMN forwarded_by_group_member_id; + +DROP INDEX idx_messages_group_id_shared_msg_id; +DROP INDEX idx_messages_forwarded_by_group_member_id; +DROP INDEX idx_messages_author_group_member_id; +ALTER TABLE messages DROP COLUMN forwarded_by_group_member_id; +ALTER TABLE messages DROP COLUMN author_group_member_id; + +DROP INDEX idx_group_members_invited_by_group_member_id; +ALTER TABLE group_members DROP COLUMN peer_chat_max_version; +ALTER TABLE group_members DROP COLUMN peer_chat_min_version; +ALTER TABLE group_members DROP COLUMN invited_by_group_member_id; + +DROP INDEX idx_group_member_intros_re_group_member_id; +ALTER TABLE group_member_intros DROP COLUMN intro_chat_protocol_version; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 875ee91de2..6f576a75e9 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -147,6 +147,9 @@ CREATE TABLE group_members( member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, show_messages INTEGER NOT NULL DEFAULT 1, xgrplinkmem_received INTEGER NOT NULL DEFAULT 0, + invited_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -161,7 +164,8 @@ CREATE TABLE group_member_intros( direct_queue_info BLOB, intro_status TEXT NOT NULL, created_at TEXT CHECK(created_at NOT NULL), - updated_at TEXT CHECK(updated_at NOT NULL), -- see GroupMemberIntroStatus + updated_at TEXT CHECK(updated_at NOT NULL), + intro_chat_protocol_version INTEGER NOT NULL DEFAULT 3, -- see GroupMemberIntroStatus UNIQUE(re_group_member_id, to_group_member_id) ); CREATE TABLE files( @@ -322,7 +326,9 @@ CREATE TABLE messages( connection_id INTEGER DEFAULT NULL REFERENCES connections ON DELETE CASCADE, group_id INTEGER DEFAULT NULL REFERENCES groups ON DELETE CASCADE, shared_msg_id BLOB, - shared_msg_id_user INTEGER + shared_msg_id_user INTEGER, + author_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL ); CREATE TABLE msg_deliveries( msg_delivery_id INTEGER PRIMARY KEY, @@ -372,7 +378,8 @@ CREATE TABLE chat_items( timed_delete_at TEXT, item_live INTEGER, item_deleted_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, - item_deleted_ts TEXT + item_deleted_ts TEXT, + forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL ); CREATE TABLE chat_item_messages( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, @@ -752,3 +759,22 @@ CREATE INDEX idx_contact_profiles_contact_link ON contact_profiles( user_id, contact_link ); +CREATE INDEX idx_group_member_intros_re_group_member_id ON group_member_intros( + re_group_member_id +); +CREATE INDEX idx_group_members_invited_by_group_member_id ON group_members( + invited_by_group_member_id +); +CREATE INDEX idx_messages_author_group_member_id ON messages( + author_group_member_id +); +CREATE INDEX idx_messages_forwarded_by_group_member_id ON messages( + forwarded_by_group_member_id +); +CREATE INDEX idx_messages_group_id_shared_msg_id ON messages( + group_id, + shared_msg_id +); +CREATE INDEX idx_chat_items_forwarded_by_group_member_id ON chat_items( + forwarded_by_group_member_id +); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index b444888145..8888ed13e0 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -152,7 +152,8 @@ mobileChatOpts dbFilePrefix dbKey = logServerHosts = True, logAgent = Nothing, logFile = Nothing, - tbqSize = 1024 + tbqSize = 1024, + highlyAvailable = False }, chatCmd = "", chatCmdDelay = 3, diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 0b39b8dd4f..04aef29dfa 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -54,7 +54,8 @@ data CoreChatOpts = CoreChatOpts logServerHosts :: Bool, logAgent :: Maybe LogLevel, logFile :: Maybe FilePath, - tbqSize :: Natural + tbqSize :: Natural, + highlyAvailable :: Bool } agentLogLevel :: ChatLogLevel -> LogLevel @@ -172,6 +173,11 @@ coreChatOptsP appDir defaultDbFileName = do <> value 1024 <> showDefault ) + highlyAvailable <- + switch + ( long "ha" + <> help "Run as a highly available client (this may increase traffic in groups)" + ) pure CoreChatOpts { dbFilePrefix, @@ -184,7 +190,8 @@ coreChatOptsP appDir defaultDbFileName = do logServerHosts = logServerHosts || logLevel <= CLLInfo, logAgent = if logAgent || logLevel == CLLDebug then Just $ agentLogLevel logLevel else Nothing, logFile, - tbqSize + tbqSize, + highlyAvailable } where useTcpTimeout p t = 1000000 * if t > 0 then t else maybe 5 (const 10) p diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 43ca5913f7..d1a2e476e5 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} @@ -19,7 +20,7 @@ module Simplex.Chat.Protocol where import Control.Applicative ((<|>)) import Control.Monad ((<=<)) -import Data.Aeson (FromJSON, ToJSON, (.:), (.:?), (.=)) +import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.KeyMap as JM @@ -51,7 +52,7 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) currentChatVersion :: Version -currentChatVersion = 3 +currentChatVersion = 4 supportedChatVRange :: VersionRange supportedChatVRange = mkVersionRange 1 currentChatVersion @@ -68,6 +69,10 @@ xGrpDirectInvVRange = mkVersionRange 2 currentChatVersion groupLinkNoContactVRange :: VersionRange groupLinkNoContactVRange = mkVersionRange 3 currentChatVersion +-- version range that supports group forwarding +groupForwardVRange :: VersionRange +groupForwardVRange = mkVersionRange 4 currentChatVersion + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -128,7 +133,7 @@ data AppMessageJson = AppMessageJson event :: Text, params :: J.Object } - deriving (Generic, FromJSON) + deriving (Eq, Show, Generic, FromJSON) data AppMessageBinary = AppMessageBinary { msgId :: Maybe SharedMsgId, @@ -208,7 +213,6 @@ instance StrEncoding AChatMessage where data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json - XMsgFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json @@ -230,13 +234,14 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json XGrpMemRole :: MemberId -> GroupMemberRole -> ChatMsgEvent 'Json - XGrpMemCon :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented + XGrpMemCon :: MemberId -> ChatMsgEvent 'Json XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented XGrpMemDel :: MemberId -> ChatMsgEvent 'Json XGrpLeave :: ChatMsgEvent 'Json XGrpDel :: ChatMsgEvent 'Json XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> ChatMsgEvent 'Json + XGrpMsgForward :: MemberId -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json XInfoProbe :: Probe -> ChatMsgEvent 'Json XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json XInfoProbeOk :: Probe -> ChatMsgEvent 'Json @@ -257,6 +262,30 @@ data AChatMsgEvent = forall e. MsgEncodingI e => ACME (SMsgEncoding e) (ChatMsgE deriving instance Show AChatMsgEvent +isForwardedGroupMsg :: ChatMsgEvent e -> Bool +isForwardedGroupMsg ev = case ev of + XMsgNew mc -> case mcExtMsgContent mc of + ExtMsgContent {file = Just FileInvitation {fileInline = Just _}} -> False + _ -> True + XMsgFileDescr _ _ -> True + XMsgUpdate {} -> True + XMsgDel _ _ -> True + XMsgReact {} -> True + XFileCancel _ -> True + XInfo _ -> True + XGrpMemNew _ -> True + XGrpMemRole {} -> True + XGrpMemDel _ -> True -- TODO there should be a special logic when deleting host member (e.g., host forwards it before deleting connections) + XGrpLeave -> True + XGrpDel -> True -- TODO there should be a special logic - host should forward before deleting connections + XGrpInfo _ -> True + _ -> False + +forwardedGroupMsg :: forall e. MsgEncodingI e => ChatMessage e -> Maybe (ChatMessage 'Json) +forwardedGroupMsg msg@ChatMessage {chatMsgEvent} = case encoding @e of + SJson | isForwardedGroupMsg chatMsgEvent -> Just msg + _ -> Nothing + data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) @@ -551,7 +580,6 @@ instance FromField MsgContent where data CMEventTag (e :: MsgEncoding) where XMsgNew_ :: CMEventTag 'Json XMsgFileDescr_ :: CMEventTag 'Json - XMsgFileCancel_ :: CMEventTag 'Json XMsgUpdate_ :: CMEventTag 'Json XMsgDel_ :: CMEventTag 'Json XMsgDeleted_ :: CMEventTag 'Json @@ -580,6 +608,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpDel_ :: CMEventTag 'Json XGrpInfo_ :: CMEventTag 'Json XGrpDirectInv_ :: CMEventTag 'Json + XGrpMsgForward_ :: CMEventTag 'Json XInfoProbe_ :: CMEventTag 'Json XInfoProbeCheck_ :: CMEventTag 'Json XInfoProbeOk_ :: CMEventTag 'Json @@ -600,7 +629,6 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where strEncode = \case XMsgNew_ -> "x.msg.new" XMsgFileDescr_ -> "x.msg.file.descr" - XMsgFileCancel_ -> "x.msg.file.cancel" XMsgUpdate_ -> "x.msg.update" XMsgDel_ -> "x.msg.del" XMsgDeleted_ -> "x.msg.deleted" @@ -629,6 +657,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpDel_ -> "x.grp.del" XGrpInfo_ -> "x.grp.info" XGrpDirectInv_ -> "x.grp.direct.inv" + XGrpMsgForward_ -> "x.grp.msg.forward" XInfoProbe_ -> "x.info.probe" XInfoProbeCheck_ -> "x.info.probe.check" XInfoProbeOk_ -> "x.info.probe.ok" @@ -650,7 +679,6 @@ instance StrEncoding ACMEventTag where ('x', t) -> pure . ACMEventTag SJson $ case t of "x.msg.new" -> XMsgNew_ "x.msg.file.descr" -> XMsgFileDescr_ - "x.msg.file.cancel" -> XMsgFileCancel_ "x.msg.update" -> XMsgUpdate_ "x.msg.del" -> XMsgDel_ "x.msg.deleted" -> XMsgDeleted_ @@ -679,6 +707,7 @@ instance StrEncoding ACMEventTag where "x.grp.del" -> XGrpDel_ "x.grp.info" -> XGrpInfo_ "x.grp.direct.inv" -> XGrpDirectInv_ + "x.grp.msg.forward" -> XGrpMsgForward_ "x.info.probe" -> XInfoProbe_ "x.info.probe.check" -> XInfoProbeCheck_ "x.info.probe.ok" -> XInfoProbeOk_ @@ -696,7 +725,6 @@ toCMEventTag :: ChatMsgEvent e -> CMEventTag e toCMEventTag msg = case msg of XMsgNew _ -> XMsgNew_ XMsgFileDescr _ _ -> XMsgFileDescr_ - XMsgFileCancel _ -> XMsgFileCancel_ XMsgUpdate {} -> XMsgUpdate_ XMsgDel {} -> XMsgDel_ XMsgDeleted -> XMsgDeleted_ @@ -725,6 +753,7 @@ toCMEventTag msg = case msg of XGrpDel -> XGrpDel_ XGrpInfo _ -> XGrpInfo_ XGrpDirectInv _ _ -> XGrpDirectInv_ + XGrpMsgForward {} -> XGrpMsgForward_ XInfoProbe _ -> XInfoProbe_ XInfoProbeCheck _ -> XInfoProbeCheck_ XInfoProbeOk _ -> XInfoProbeOk_ @@ -795,7 +824,6 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do msg = \case XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" - XMsgFileCancel_ -> XMsgFileCancel <$> p "msgId" XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> opt "ttl" <*> opt "live" XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" XMsgDeleted_ -> pure XMsgDeleted @@ -824,6 +852,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpDel_ -> pure XGrpDel XGrpInfo_ -> XGrpInfo <$> p "groupProfile" XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" + XGrpMsgForward_ -> XGrpMsgForward <$> p "memberId" <*> p "msg" <*> p "msgTs" XInfoProbe_ -> XInfoProbe <$> p "probe" XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" XInfoProbeOk_ -> XInfoProbeOk <$> p "probe" @@ -855,7 +884,6 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ params = \case XMsgNew container -> msgContainerJSON container XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] - XMsgFileCancel msgId' -> o ["msgId" .= msgId'] XMsgUpdate msgId' content ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId -> o $ ("memberId" .=? memberId) ["msgId" .= msgId'] XMsgDeleted -> JM.empty @@ -884,6 +912,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpDel -> JM.empty XGrpInfo p -> o ["groupProfile" .= p] XGrpDirectInv connReq content -> o $ ("content" .=? content) ["connReq" .= connReq] + XGrpMsgForward memberId msg msgTs -> o ["memberId" .= memberId, "msg" .= msg, "msgTs" .= msgTs] XInfoProbe probe -> o ["probe" .= probe] XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] XInfoProbeOk probe -> o ["probe" .= probe] @@ -894,3 +923,9 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XCallEnd callId -> o ["callId" .= callId] XOk -> JM.empty XUnknown _ ps -> ps + +instance ToJSON (ChatMessage 'Json) where + toJSON = (\(AMJson msg) -> toJSON msg) . chatToAppMessage + +instance FromJSON (ChatMessage 'Json) where + parseJSON v = appJsonToCM <$?> parseJSON v diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index b5b377ea51..53c7d249a6 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -98,13 +98,13 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, - m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 40294dc141..673ef4d805 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -45,6 +45,7 @@ module Simplex.Chat.Store.Groups getGroupInfoByName, getGroupMember, getGroupMemberById, + getGroupMemberByMemberId, getGroupMembers, getGroupMembersForExpiration, getGroupCurrentMembersCount, @@ -77,6 +78,9 @@ module Simplex.Chat.Store.Groups createIntroductions, updateIntroStatus, saveIntroInvitation, + getIntroduction, + getForwardIntroducedMembers, + getForwardInvitedMembers, createIntroReMember, createIntroToMemberContact, saveMemberInvitation, @@ -125,6 +129,7 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages +import Simplex.Chat.Protocol (currentChatVersion, groupForwardVRange, supportedChatVRange) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -140,9 +145,9 @@ import UnliftIO.STM type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow -type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, Version, Version, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) -type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe Version, Maybe Version, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs) :. userMemberRow) = @@ -153,16 +158,17 @@ toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, de in GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing + memberChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in GroupMember {..} toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -257,13 +263,13 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, - m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version @@ -308,14 +314,14 @@ createNewGroup db gVar user@User {userId} groupProfile incognitoProfile = Except (ldn, userId, profileId, True, currentTs, currentTs, currentTs) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 - membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs supportedChatVRange let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs} -- | creates a new group record for the group the current user was invited to, or returns an existing one createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do +createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Just hostConn@Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do @@ -353,8 +359,9 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?,?)" (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs) insertedRowId db - GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs - membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs + let JVersionRange hostVRange = hostConn.peerChatVRange + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs supportedChatVRange let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure (GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs}, groupMemberId) @@ -363,8 +370,8 @@ getHostMemberId_ db User {userId} groupId = ExceptT . firstRow fromOnly (SEHostMemberIdNotFound groupId) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ?" (userId, groupId, GCHostMember) -createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> ExceptT StoreError IO GroupMember -createContactMemberInv_ db User {userId, userContactId} groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt = do +createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRange -> ExceptT StoreError IO GroupMember +createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt memberChatVRange@(VersionRange minV maxV) = do incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId (localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of (Just profile@LocalProfile {displayName}, Just profileId) -> @@ -381,11 +388,13 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me memberStatus, memberSettings = defaultMemberSettings, invitedBy, + invitedByGroupMemberId, localDisplayName, memberProfile, memberContactId = Just $ contactId' userOrContact, memberContactProfileId = localProfileId (profile' userOrContact), - activeConn = Nothing + activeConn = Nothing, + memberChatVRange = JVersionRange memberChatVRange } where insertMember_ :: IO ContactName @@ -395,12 +404,14 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy) + ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) + :. (minV, maxV) ) pure localDisplayName insertMemberIncognitoProfile_ :: ContactName -> ProfileId -> ExceptT StoreError IO ContactName @@ -410,12 +421,14 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy) + ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + :. (minV, maxV) ) pure $ Right incognitoLdn @@ -430,7 +443,7 @@ createGroupInvitedViaLink hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact - void $ createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs supportedChatVRange liftIO $ setViaGroupLinkHash db groupId connId (,) <$> getGroupInfo db user groupId <*> getGroupMemberById db user hostMemberId where @@ -552,8 +565,8 @@ getUserGroupDetails db User {userId, userContactId} _contactId_ search_ = db [sql| SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, - mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, - mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences + mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, + mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -617,8 +630,8 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, - m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version @@ -647,6 +660,14 @@ getGroupMemberById db user@User {userId} groupMemberId = (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (userId, groupMemberId, userId) +getGroupMemberByMemberId :: DB.Connection -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember +getGroupMemberByMemberId db user@User {userId} GroupInfo {groupId} memberId = + ExceptT . firstRow (toContactMember user) (SEGroupMemberNotFoundByMemberId memberId) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") + (userId, groupId, memberId) + getGroupMembers :: DB.Connection -> User -> GroupInfo -> IO [GroupMember] getGroupMembers db user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember user) @@ -705,15 +726,17 @@ getGroupInvitation db user groupId = firstRow fromOnly (SEGroupNotFound groupId) $ DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?" (groupId, userId) -createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember createNewContactMember _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ _ = throwError $ SEContactNotReady localDisplayName -createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Just Connection {peerChatVRange}} memberRole agentConnId connRequest subMode = +createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile, activeConn = Just Connection {peerChatVRange}} memberRole agentConnId connRequest subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt void $ createMemberConnection_ db userId groupMemberId agentConnId (fromJVersionRange peerChatVRange) Nothing 0 createdAt subMode pure member where + JVersionRange (VersionRange minV maxV) = peerChatVRange + invitedByGroupMemberId = groupMemberId' membership createMember_ memberId createdAt = do insertMember_ groupMemberId <- liftIO $ insertedRowId db @@ -727,11 +750,13 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con memberStatus = GSMemInvited, memberSettings = defaultMemberSettings, invitedBy = IBUser, + invitedByGroupMemberId = Just invitedByGroupMemberId, localDisplayName, memberProfile = profile, memberContactId = Just contactId, memberContactProfileId = localProfileId profile, - activeConn = Nothing + activeConn = Nothing, + memberChatVRange = peerChatVRange } where insertMember_ = @@ -739,16 +764,18 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser) + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) + :. (minV, maxV) ) -createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> ExceptT StoreError IO () -createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange subMode = +createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> ExceptT StoreError IO () +createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange subMode = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt @@ -756,17 +783,20 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode setCommandConnId db user cmdId connId where + VersionRange minV maxV = peerChatVRange insertMember_ memberId createdAt = DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser) + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) + :. (minV, maxV) ) createAcceptedMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> ExceptT StoreError IO (GroupMemberId, MemberId) @@ -774,8 +804,8 @@ createAcceptedMember db gVar User {userId, userContactId} - GroupInfo {groupId} - UserContactRequest {localDisplayName, profileId} + GroupInfo {groupId, membership} + UserContactRequest {cReqChatVRange, localDisplayName, profileId} memberRole = do liftIO $ DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) @@ -785,17 +815,20 @@ createAcceptedMember groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) where + JVersionRange (VersionRange minV maxV) = cReqChatVRange insertMember_ memberId createdAt = DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser) + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) + :. (minV, maxV) ) createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () @@ -864,8 +897,8 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do (memStatus, currentTs, userId, groupMemberId) -- | add new member with profile -createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user gInfo memInfo@MemberInfo {profile} memCategory memStatus = do +createNewGroupMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do currentTs <- liftIO getCurrentTime (localDisplayName, memProfileId) <- createNewMemberProfile_ db user profile currentTs let newMember = @@ -874,6 +907,7 @@ createNewGroupMember db user gInfo memInfo@MemberInfo {profile} memCategory memS memCategory, memStatus, memInvitedBy = IBUnknown, + memInvitedByGroupMemberId = Just $ groupMemberId' invitingMember, localDisplayName, memContactId = Nothing, memProfileId @@ -896,10 +930,11 @@ createNewMember_ User {userId, userContactId} GroupInfo {groupId} NewGroupMember - { memInfo = MemberInfo memberId memberRole _ memberProfile, + { memInfo = MemberInfo memberId memberRole memChatVRange memberProfile, memCategory = memberCategory, memStatus = memberStatus, memInvitedBy = invitedBy, + memInvitedByGroupMemberId, localDisplayName, memContactId = memberContactId, memProfileId = memberContactProfileId @@ -907,18 +942,38 @@ createNewMember_ createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing + mcvr@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange DB.execute db [sql| INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, - invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + (group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) + ( (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, memInvitedByGroupMemberId) + :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) + :. (minV, maxV) + ) groupMemberId <- insertedRowId db - let memberSettings = defaultMemberSettings - pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, memberSettings, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn} + pure GroupMember { + groupMemberId, + groupId, + memberId, + memberRole, + memberCategory, + memberStatus, + memberSettings = defaultMemberSettings, + invitedBy, + invitedByGroupMemberId = memInvitedByGroupMemberId, + localDisplayName, + memberProfile = toLocalProfile memberContactProfileId memberProfile "", + memberContactId, + memberContactProfileId, + activeConn, + memberChatVRange = JVersionRange mcvr + } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) checkGroupMemberHasItems db User {userId} GroupMember {groupMemberId, groupId} = @@ -965,10 +1020,10 @@ createIntroductions db members toMember = do db [sql| INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, created_at, updated_at) - VALUES (?,?,?,?,?) + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) |] - (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, ts, ts) + (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, currentChatVersion, ts, ts) introId <- insertedRowId db pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending, introInvitation = Nothing} @@ -986,7 +1041,7 @@ updateIntroStatus db introId introStatus = do saveIntroInvitation :: DB.Connection -> GroupMember -> GroupMember -> IntroInvitation -> ExceptT StoreError IO GroupMemberIntro saveIntroInvitation db reMember toMember introInv = do - intro <- getIntroduction_ db reMember toMember + intro <- getIntroduction db reMember toMember liftIO $ do currentTs <- getCurrentTime DB.executeNamed @@ -1027,8 +1082,8 @@ saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnRe ":group_member_id" := groupMemberId ] -getIntroduction_ :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro -getIntroduction_ db reMember toMember = ExceptT $ do +getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro +getIntroduction db reMember toMember = ExceptT $ do toIntro <$> DB.query db @@ -1045,9 +1100,49 @@ getIntroduction_ db reMember toMember = ExceptT $ do in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound +getForwardIntroducedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember] +getForwardIntroducedMembers db user invitee highlyAvailable = do + memberIds <- map fromOnly <$> query + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + where + mId = groupMemberId' invitee + query + | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) + | otherwise = + DB.query + db + (q <> " AND intro_chat_protocol_version >= ?") + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + q = + [sql| + SELECT re_group_member_id + FROM group_member_intros + WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) + |] + +getForwardInvitedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember] +getForwardInvitedMembers db user forwardMember highlyAvailable = do + memberIds <- map fromOnly <$> query + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + where + mId = groupMemberId' forwardMember + query + | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) + | otherwise = + DB.query + db + (q <> " AND intro_chat_protocol_version >= ?") + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + q = + [sql| + SELECT to_group_member_id + FROM group_member_intros + WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) + |] + createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember -createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do - let mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange +createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do + let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- liftIO getCurrentTime newMember <- case directConnIds of @@ -1056,10 +1151,10 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM liftIO $ setCommandConnId db user directCmdId directConnId (localDisplayName, contactId, memProfileId) <- createContact_ db userId memberProfile "" (Just groupId) currentTs Nothing liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, directConnId) - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId} + pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Just contactId, memProfileId} Nothing -> do (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Nothing, memProfileId} + pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId mcvr memberContactId cLevel currentTs subMode @@ -1116,13 +1211,13 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, - m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version @@ -1209,8 +1304,8 @@ getGroupInfo db User {userId, userContactId} groupId = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 0136ac6609..d98023ad83 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -23,6 +23,7 @@ module Simplex.Chat.Store.Messages createNewSndMessage, createSndMsgDelivery, createNewMessageAndRcvMsgDelivery, + createNewRcvMessage, createSndMsgDeliveryEvent, createRcvMsgDeliveryEvent, createPendingGroupMessage, @@ -185,25 +186,53 @@ createSndMsgDelivery db sndMsgDelivery messageId = do createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent currentTs pure msgDeliveryId -createNewMessageAndRcvMsgDelivery :: forall e. MsgEncodingI e => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> RcvMsgDelivery -> IO RcvMessage -createNewMessageAndRcvMsgDelivery db connOrGroupId NewMessage {chatMsgEvent, msgBody} sharedMsgId_ RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} = do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO messages (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id) VALUES (?,?,?,?,?,?,?,?)" - (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_) - msgId <- insertedRowId db - DB.execute - db - "INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, agent_msg_meta, agent_ack_cmd_id, chat_ts, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (msgId, connId, agentMsgId, msgMetaJson agentMsgMeta, agentAckCmdId, snd $ broker agentMsgMeta, currentTs, currentTs) - msgDeliveryId <- insertedRowId db - createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs - pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody} - where - (connId_, groupId_) = case connOrGroupId of - ConnectionId connId' -> (Just connId', Nothing) - GroupId groupId -> (Nothing, Just groupId) +createNewMessageAndRcvMsgDelivery :: forall e. MsgEncodingI e => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> RcvMsgDelivery -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage +createNewMessageAndRcvMsgDelivery db connOrGroupId newMessage sharedMsgId_ RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} authorGroupMemberId_ = do + msg@RcvMessage {msgId} <- createNewRcvMessage db connOrGroupId newMessage sharedMsgId_ authorGroupMemberId_ Nothing + liftIO $ do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, agent_msg_meta, agent_ack_cmd_id, chat_ts, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (msgId, connId, agentMsgId, msgMetaJson agentMsgMeta, agentAckCmdId, snd $ broker agentMsgMeta, currentTs, currentTs) + msgDeliveryId <- insertedRowId db + createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs + pure msg + +createNewRcvMessage :: forall e. (MsgEncodingI e) => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage +createNewRcvMessage db connOrGroupId NewMessage{chatMsgEvent, msgBody} sharedMsgId_ authorGroupMemberId forwardedByGroupMemberId = + case connOrGroupId of + ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing + GroupId groupId -> case sharedMsgId_ of + Just sharedMsgId -> liftIO (duplicateGroupMsgMemberIds groupId sharedMsgId) >>= \case + Just (duplAuthorId, duplFwdMemberId) -> + throwError $ SEDuplicateGroupMessage groupId sharedMsgId duplAuthorId duplFwdMemberId + Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId + Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId + where + duplicateGroupMsgMemberIds :: Int64 -> SharedMsgId -> IO (Maybe (Maybe GroupMemberId, Maybe GroupMemberId)) + duplicateGroupMsgMemberIds groupId sharedMsgId = + maybeFirstRow id + $ DB.query + db + [sql| + SELECT author_group_member_id, forwarded_by_group_member_id + FROM messages + WHERE group_id = ? AND shared_msg_id = ? LIMIT 1 + |] + (groupId, sharedMsgId) + insertRcvMsg connId_ groupId_ = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO messages + (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + |] + (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorGroupMemberId, forwardedByGroupMemberId) + msgId <- insertedRowId db + pure RcvMessage{msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorGroupMemberId, forwardedByGroupMemberId} createSndMsgDeliveryEvent :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> ExceptT StoreError IO () createSndMsgDeliveryEvent db connId agentMsgId sndMsgDeliveryStatus = do @@ -322,7 +351,7 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem timed live createdAt = - createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow timed live createdAt createdAt + createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow timed live createdAt Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -337,8 +366,8 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv Nothing -> (Just False, Nothing) createNewRcvChatItem :: DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c)) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent} sharedMsgId_ ciContent timed live itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow timed live itemTs createdAt +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByGroupMemberId} sharedMsgId_ ciContent timed live itemTs createdAt = do + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow timed live itemTs forwardedByGroupMemberId createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem) where @@ -353,14 +382,14 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent} shar (Just $ Just userMemberId == memberId, memberId) createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> IO ChatItemId -createNewChatItemNoMsg db user chatDirection ciContent = - createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing False +createNewChatItemNoMsg db user chatDirection ciContent itemTs = + createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing False itemTs Nothing where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CITimed -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs forwardedByGroupMemberId createdAt = do DB.execute db [sql| @@ -368,18 +397,18 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q -- user and IDs user_id, created_by_msg_id, contact_id, group_id, group_member_id, -- meta - item_sent, item_ts, item_content, item_text, item_status, shared_msg_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, + item_sent, item_ts, item_content, item_text, item_status, shared_msg_id, forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. itemRow :. quoteRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByGroupMemberId) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing) @@ -440,8 +469,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe [sql| SELECT i.chat_item_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -556,8 +585,8 @@ getGroupChatPreviews_ db User {userId, userContactId} = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat, @@ -565,19 +594,21 @@ getGroupChatPreviews_ db User {userId, userContactId} = do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- CIMeta forwardedByGroupMemberId + i.forwarded_by_group_member_id, -- Maybe GroupMember - sender - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.member_status, rm.show_messages, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -1020,7 +1051,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT DBCINotDeleted -> Nothing _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt + in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1031,7 +1062,7 @@ toDirectChatItemList _ _ = [] type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow -type MaybeGroupChatItemRow = MaybeChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow +type MaybeGroupChatItemRow = MaybeChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow toGroupQuote :: QuoteRow -> Maybe GroupMember -> Maybe (CIQuote 'CTGroup) toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction quotedSent quotedMember_ @@ -1042,8 +1073,8 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction _ _ = Nothing -- this function can be changed so it never fails, not only avoid failure on invalid json -toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) +toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -1079,13 +1110,13 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, DBCIBlocked -> Just (CIBlocked @'CTGroup deletedTs) _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt + in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByGroupMemberId createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} toGroupChatItemList :: UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = - either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) +toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = + either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) toGroupChatItemList _ _ _ = [] getAllChatItems :: DB.Connection -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] @@ -1529,19 +1560,21 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- CIMeta forwardedByGroupMemberId + i.forwarded_by_group_member_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.member_status, rm.show_messages, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index f5a4426204..e261d97e2a 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -88,6 +88,7 @@ import Simplex.Chat.Migrations.M20231010_member_settings import Simplex.Chat.Migrations.M20231019_indexes import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Chat.Migrations.M20231107_indexes +import Simplex.Chat.Migrations.M20231113_group_forward import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -175,7 +176,8 @@ schemaMigrations = ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings), ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes), ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received), - ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes) + ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes), + ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c51fcf1ebe..260c91e0ed 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -100,6 +100,7 @@ data StoreError | SEHostMemberIdNotFound {groupId :: Int64} | SEContactNotFoundByFileId {fileId :: FileTransferId} | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} + | SEDuplicateGroupMessage {groupId :: Int64, sharedMsgId :: SharedMsgId, authorGroupMemberId :: Maybe GroupMemberId, forwardedByGroupMemberId :: Maybe GroupMemberId} deriving (Show, Exception, Generic) instance ToJSON StoreError where @@ -206,6 +207,17 @@ setPeerChatVRange db connId (VersionRange minVer maxVer) = |] (minVer, maxVer, connId) +setMemberChatVRange :: DB.Connection -> GroupMemberId -> VersionRange -> IO () +setMemberChatVRange db mId (VersionRange minVer maxVer) = + DB.execute + db + [sql| + UPDATE group_members + SET peer_chat_min_version = ?, peer_chat_max_version = ? + WHERE group_member_id = ? + |] + (minVer, maxVer, mId) + setCommandConnId :: DB.Connection -> User -> CommandId -> Int64 -> IO () setCommandConnId db User {userId} cmdId connId = do updatedAt <- getCurrentTime diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index c92b25fb20..064cf78086 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -668,9 +668,9 @@ instance ToJSON MemberInfo where memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = - MemberInfo memberId memberRole memberChatVRange (fromLocalProfile memberProfile) + MemberInfo memberId memberRole cvr (fromLocalProfile memberProfile) where - memberChatVRange = ChatVersionRange . fromJVersionRange . peerChatVRange <$> activeConn + cvr = ChatVersionRange . fromJVersionRange . peerChatVRange <$> activeConn data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, @@ -692,6 +692,7 @@ data GroupMember = GroupMember memberStatus :: GroupMemberStatus, memberSettings :: GroupMemberSettings, invitedBy :: InvitedBy, + invitedByGroupMemberId :: Maybe GroupMemberId, localDisplayName :: ContactName, -- for membership, memberProfile can be either user's profile or incognito profile, based on memberIncognito test. -- for other members it's whatever profile the local user can see (there is no info about whether it's main or incognito profile for remote users). @@ -701,7 +702,10 @@ data GroupMember = GroupMember -- for membership it would always point to user's contact -- it is used to test for incognito status by comparing with ID in memberProfile memberContactProfileId :: ProfileId, - activeConn :: Maybe Connection + activeConn :: Maybe Connection, + -- member chat protocol version range; if member has active connection, its version range is preferred; + -- for membership current supportedChatVRange is set, it's not updated on protocol version increase + memberChatVRange :: JVersionRange } deriving (Eq, Show, Generic) @@ -719,11 +723,17 @@ groupMemberRef GroupMember {groupMemberId, memberProfile = p} = GroupMemberRef {groupMemberId, profile = fromLocalProfile p} memberConn :: GroupMember -> Maybe Connection -memberConn GroupMember{activeConn} = activeConn +memberConn GroupMember {activeConn} = activeConn memberConnId :: GroupMember -> Maybe ConnId memberConnId GroupMember {activeConn} = aConnId <$> activeConn +memberChatVRange' :: GroupMember -> VersionRange +memberChatVRange' GroupMember {activeConn, memberChatVRange} = + fromJVersionRange $ case activeConn of + Just Connection {peerChatVRange} -> peerChatVRange + Nothing -> memberChatVRange + groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId @@ -747,6 +757,7 @@ data NewGroupMember = NewGroupMember memCategory :: GroupMemberCategory, memStatus :: GroupMemberStatus, memInvitedBy :: InvitedBy, + memInvitedByGroupMemberId :: Maybe GroupMemberId, localDisplayName :: ContactName, memProfileId :: Int64, memContactId :: Maybe Int64 @@ -1471,7 +1482,7 @@ data GroupMemberIntroStatus | GMIntroReConnected | GMIntroToConnected | GMIntroConnected - deriving (Show) + deriving (Eq, Show) instance FromField GroupMemberIntroStatus where fromField = fromTextField_ introStatusT diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f7992571a9..982c5208af 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -450,7 +450,7 @@ viewChats ts tz = concatMap chatPreview . reverse viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} doShow ts tz = - withItemDeleted <$> case chat of + withGroupMsgForwarded . withItemDeleted <$> (case chat of DirectChat c -> case chatDir of CIDirectSnd -> case content of CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to quote mc @@ -484,11 +484,14 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} from = ttyFromGroup g m where quote = maybe [] (groupQuote g) quotedItem - _ -> [] + _ -> []) where withItemDeleted item = case chatItemDeletedText ci (chatInfoMembership chat) of Nothing -> item Just t -> item <> styled (colored Red) (" [" <> t <> "]") + withGroupMsgForwarded item = case meta.forwardedByGroupMemberId of + Nothing -> item + Just _ -> item <> styled (colored Yellow) (" [>>]" :: String) withSndFile = withFile viewSentFileInvitation withRcvFile = withFile viewReceivedFileInvitation withFile view dir l = maybe l (\f -> l <> view dir f ts tz meta) file diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index ea455a0fc1..aaf812f000 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -71,7 +71,8 @@ testOpts = logServerHosts = False, logAgent = Nothing, logFile = Nothing, - tbqSize = 16 + tbqSize = 16, + highlyAvailable = False }, chatCmd = "", chatCmdDelay = 3, diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 36b5cf4ea5..edf3d2fab8 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -7,12 +7,14 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Monad (when) +import Control.Monad (when, void) +import qualified Data.ByteString as B import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (GroupMemberRole (..)) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Version import System.Directory (copyFile) import System.FilePath (()) @@ -103,6 +105,8 @@ chatGroupTests = do it "invited member replaces member contact reference if it already exists" testMemberContactInvitedConnectionReplaced it "share incognito profile" testMemberContactIncognito it "sends and updates profile when creating contact" testMemberContactProfileUpdate + describe "forwarding messages" $ do + it "admin should forward messages between invitee and introduced" testGroupMsgForward where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -1522,6 +1526,13 @@ testGroupDelayedModeration tmp = do cath <## "#team: you joined the group" ] threadDelay 1000000 + + -- imitate not implemented group forwarding + -- (real client wouldn't have forwarding code, but tests use "current code" with configured version, + -- and forwarding client doesn't check compatibility) + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" alice ##> "\\\\ #team @cath hi" @@ -1561,6 +1572,13 @@ testGroupDelayedModerationFullDelete tmp = do cath <## "#team: you joined the group" ] threadDelay 1000000 + + -- imitate not implemented group forwarding + -- (real client wouldn't have forwarding code, but tests use "current code" with configured version, + -- and forwarding client doesn't check compatibility) + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" alice ##> "\\\\ #team @cath hi" @@ -3644,9 +3662,9 @@ testMemberContactProhibitedRepeatInv = testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \b -> withTestOutput b $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \c -> withTestOutput c $ \cath -> do + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat tmp "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -3881,3 +3899,109 @@ testMemberContactProfileUpdate = cath #> "#team hello there" alice <# "#team kate> hello there" bob <# "#team kate> hello there" -- updated profile + +testGroupMsgForward :: HasCallStack => FilePath -> IO () +testGroupMsgForward = + testChatCfg4 cfg aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> withXFTPServer $ do + createGroup3 "team" alice bob cath + + threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + + void $ withCCTransaction bob $ \db -> + DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" + void $ withCCTransaction cath $ \db -> + DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + + threadDelay 1000000 + + cath #> "#team hey team" + alice <# "#team cath> hey team" + bob <# "#team cath> hey team [>>]" + + alice ##> "/tail #team 2" + alice <# "#team bob> hi there" + alice <# "#team cath> hey team" + + bob ##> "/tail #team 2" + bob <# "#team hi there" + bob <# "#team cath> hey team [>>]" + + cath ##> "/tail #team 2" + cath <# "#team bob> hi there [>>]" + cath <# "#team hey team" + + bob ##> "! #team hello there" + bob <# "#team [edited] hello there" + alice <# "#team bob> [edited] hello there" + cath <# "#team bob> [edited] hello there" -- TODO show as forwarded + + cath ##> "+1 #team hello there" + cath <## "added 👍" + alice <# "#team cath> > bob hello there" + alice <## " + 👍" + bob <# "#team cath> > bob hello there" + bob <## " + 👍" + + bob ##> "\\ #team hello there" + bob <## "message marked deleted" + alice <# "#team bob> [marked deleted] hello there" + cath <# "#team bob> [marked deleted] hello there" -- TODO show as forwarded + + bob #> "/f #team ./tests/fixtures/test.jpg" + bob <## "use /fc 1 to cancel sending" + bob <## "completed uploading file 1 (test.jpg) for #team" + concurrentlyN_ + [ do + alice <# "#team bob> sends file test.jpg (136.5 KiB / 139737 bytes)" + alice <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team bob> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## "use /fr 1 [/ | ] to receive it [>>]" + ] + cath ##> "/fr 1 ./tests/tmp" + cath <## "saving file 1 from bob to ./tests/tmp/test.jpg" + cath <## "started receiving file 1 (test.jpg) from bob" + cath <## "completed receiving file 1 (test.jpg) from bob" + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + + cath ##> "/mr #team bob member" + cath <## "#team: you changed the role of bob from admin to member" + alice <## "#team: cath changed the role of bob from admin to member" + bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded + + connectUsers cath dan + cath ##> "/a #team dan" + cath <## "invitation to join the group #team sent to dan" + dan <## "#team: cath invites you to join the group as member" + dan <## "use /j team to accept" + dan ##> "/j #team" + dan <## "#team: you joined the group" + concurrentlyN_ + [ cath <## "#team: dan joined the group", + do + alice <## "#team: cath added dan (Daniel) to the group (connecting...)" + alice <## "#team: new member dan is connected", + -- bob will not connect to dan, as introductions are not forwarded (yet?) + bob <## "#team: cath added dan (Daniel) to the group (connecting...)", -- TODO show as forwarded + dan <## "#team: member alice (Alice) is connected" + ] + dan #> "#team hello all" + alice <# "#team dan> hello all" + -- bob <# "#team dan> hello all [>>]" + cath <# "#team dan> hello all" + + bob #> "#team hi all" + alice <# "#team bob> hi all" + cath <# "#team bob> hi all [>>]" + -- dan <# "#team bob> hi all" + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index f5c1bf8560..884d2b873e 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -122,7 +122,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-3\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-4\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -232,13 +232,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-4\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-4\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.inv" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" @@ -250,7 +250,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-4\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -276,6 +276,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.grp.direct.inv without content" $ "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XGrpDirectInv testConnReq Nothing + -- it "x.grp.msg.forward" + -- $ "{\"v\":\"1\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}" + -- #==# XGrpMsgForward + -- (MemberId "\1\2\3\4") + -- (ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)))) + -- (systemToUTCTime $ MkSystemTime 1 1) it "x.info.probe" $ "{\"v\":\"1\",\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbe (Probe "\1\2\3\4") From e95d9d0b49cdbb66440bba62ad55eba5938fad22 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 19:18:02 +0000 Subject: [PATCH 209/294] core: rename migration to remote-control, comments (#3393) --- simplex-chat.cabal | 2 +- .../Migrations/M20231114_remote_control.hs | 45 ++++++++++++++++++ .../Migrations/M20231114_remote_controller.hs | 47 ------------------- src/Simplex/Chat/Migrations/chat_schema.sql | 38 +++++++-------- src/Simplex/Chat/Store/Migrations.hs | 4 +- 5 files changed, 66 insertions(+), 70 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231114_remote_control.hs delete mode 100644 src/Simplex/Chat/Migrations/M20231114_remote_controller.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d40d3239da..abcfcc4c45 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -123,7 +123,7 @@ library Simplex.Chat.Migrations.M20231030_xgrplinkmem_received Simplex.Chat.Migrations.M20231107_indexes Simplex.Chat.Migrations.M20231113_group_forward - Simplex.Chat.Migrations.M20231114_remote_controller + Simplex.Chat.Migrations.M20231114_remote_control Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat/Migrations/M20231114_remote_control.hs b/src/Simplex/Chat/Migrations/M20231114_remote_control.hs new file mode 100644 index 0000000000..e716b2aa63 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231114_remote_control.hs @@ -0,0 +1,45 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231114_remote_control where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231114_remote_control :: Query +m20231114_remote_control = + [sql| +CREATE TABLE remote_hosts ( -- e.g., mobiles known to a desktop app + remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, + host_device_name TEXT NOT NULL, + store_path TEXT NOT NULL, -- relative folder name for host files + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + id_key BLOB NOT NULL, -- long-term/identity signing key + host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected + host_dh_pub BLOB NOT NULL -- last session DH key +); + +CREATE UNIQUE INDEX idx_remote_hosts_host_fingerprint ON remote_hosts(host_fingerprint); + +CREATE TABLE remote_controllers ( -- e.g., desktops known to a mobile app + remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, + ctrl_device_name TEXT NOT NULL, + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + ctrl_fingerprint BLOB NOT NULL, -- remote controller CA cert fingerprint, set when connected + id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures + dh_priv_key BLOB NOT NULL, -- last session DH key + prev_dh_priv_key BLOB -- previous session DH key +); + +CREATE UNIQUE INDEX idx_remote_controllers_ctrl_fingerprint ON remote_controllers(ctrl_fingerprint); +|] + +down_m20231114_remote_control :: Query +down_m20231114_remote_control = + [sql| +DROP INDEX idx_remote_hosts_host_fingerprint; +DROP INDEX idx_remote_controllers_ctrl_fingerprint; +DROP TABLE remote_hosts; +DROP TABLE remote_controllers; +|] diff --git a/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs b/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs deleted file mode 100644 index a8e92a9988..0000000000 --- a/src/Simplex/Chat/Migrations/M20231114_remote_controller.hs +++ /dev/null @@ -1,47 +0,0 @@ -{-# LANGUAGE QuasiQuotes #-} - -module Simplex.Chat.Migrations.M20231114_remote_controller where - -import Database.SQLite.Simple (Query) -import Database.SQLite.Simple.QQ (sql) - -m20231114_remote_controller :: Query -m20231114_remote_controller = - [sql| -CREATE TABLE remote_hosts ( -- hosts known to a controlling app - remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, - host_device_name TEXT NOT NULL, - store_path TEXT NOT NULL, -- file path for host files relative to app storage (must not contain "/") - -- RCHostPairing - ca_key BLOB NOT NULL, -- private key to sign session certificates - ca_cert BLOB NOT NULL, -- root certificate - id_key BLOB NOT NULL, -- long-term/identity signing key - -- KnownHostPairing - host_fingerprint BLOB NOT NULL, -- pinned remote host CA, set when connected - -- stored host session key - host_dh_pub BLOB NOT NULL, -- session DH key - UNIQUE (host_fingerprint) ON CONFLICT FAIL -); - -CREATE TABLE remote_controllers ( -- controllers known to a hosting app - remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, - ctrl_device_name TEXT NOT NULL, - -- RCCtrlPairing - ca_key BLOB NOT NULL, -- CA key - ca_cert BLOB NOT NULL, -- CA certificate for TLS clients - ctrl_fingerprint BLOB NOT NULL, -- remote controller CA, set when connected - id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures - -- stored session key, commited on connection confirmation - dh_priv_key BLOB NOT NULL, -- session DH key - -- prev session key - prev_dh_priv_key BLOB, -- previous session DH key - UNIQUE (ctrl_fingerprint) ON CONFLICT FAIL -); -|] - -down_m20231114_remote_controller :: Query -down_m20231114_remote_controller = - [sql| -DROP TABLE remote_hosts; -DROP TABLE remote_controllers; -|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index f6aed76982..bc441ec6ff 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -528,34 +528,26 @@ CREATE TABLE IF NOT EXISTS "received_probes"( updated_at TEXT CHECK(updated_at NOT NULL) ); CREATE TABLE remote_hosts( - -- hosts known to a controlling app + -- e.g., mobiles known to a desktop app remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, host_device_name TEXT NOT NULL, - store_path TEXT NOT NULL, -- file path for host files relative to app storage(must not contain "/") - -- RCHostPairing - ca_key BLOB NOT NULL, -- private key to sign session certificates - ca_cert BLOB NOT NULL, -- root certificate + store_path TEXT NOT NULL, -- relative folder name for host files + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, id_key BLOB NOT NULL, -- long-term/identity signing key - -- KnownHostPairing - host_fingerprint BLOB NOT NULL, -- pinned remote host CA, set when connected - -- stored host session key - host_dh_pub BLOB NOT NULL, -- session DH key - UNIQUE(host_fingerprint) ON CONFLICT FAIL + host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected + host_dh_pub BLOB NOT NULL -- last session DH key ); CREATE TABLE remote_controllers( - -- controllers known to a hosting app + -- e.g., desktops known to a mobile app remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, ctrl_device_name TEXT NOT NULL, - -- RCCtrlPairing - ca_key BLOB NOT NULL, -- CA key - ca_cert BLOB NOT NULL, -- CA certificate for TLS clients - ctrl_fingerprint BLOB NOT NULL, -- remote controller CA, set when connected + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + ctrl_fingerprint BLOB NOT NULL, -- remote controller CA cert fingerprint, set when connected id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures - -- stored session key, commited on connection confirmation - dh_priv_key BLOB NOT NULL, -- session DH key - -- prev session key - prev_dh_priv_key BLOB, -- previous session DH key - UNIQUE(ctrl_fingerprint) ON CONFLICT FAIL + dh_priv_key BLOB NOT NULL, -- last session DH key + prev_dh_priv_key BLOB -- previous session DH key ); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, @@ -808,3 +800,9 @@ CREATE INDEX idx_messages_group_id_shared_msg_id ON messages( CREATE INDEX idx_chat_items_forwarded_by_group_member_id ON chat_items( forwarded_by_group_member_id ); +CREATE UNIQUE INDEX idx_remote_hosts_host_fingerprint ON remote_hosts( + host_fingerprint +); +CREATE UNIQUE INDEX idx_remote_controllers_ctrl_fingerprint ON remote_controllers( + ctrl_fingerprint +); diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index f7b10971cf..7b9ead1b10 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -89,7 +89,7 @@ import Simplex.Chat.Migrations.M20231019_indexes import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Chat.Migrations.M20231107_indexes import Simplex.Chat.Migrations.M20231113_group_forward -import Simplex.Chat.Migrations.M20231114_remote_controller +import Simplex.Chat.Migrations.M20231114_remote_control import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -179,7 +179,7 @@ schemaMigrations = ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received), ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes), ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward), - ("20231114_remote_controller", m20231114_remote_controller, Just down_m20231114_remote_controller) + ("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control) ] -- | The list of migrations in ascending order by date From ca8833c0c1599dde3ad4631c7f91cdb8bbce819b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 20:11:30 +0000 Subject: [PATCH 210/294] desktop: sending and receiving files via connected mobile (#3365) * desktop: support remote files (WIP) * working with remote files locally * better * working with remote file downloads * sending files * fixes of loading files in some situations * image compression * constant for remote hosts * refactor --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../simplex/common/platform/Files.android.kt | 2 + .../common/views/helpers/Utils.android.kt | 2 +- .../chat/simplex/common/model/ChatModel.kt | 59 +++++++++++++++- .../chat/simplex/common/model/SimpleXAPI.kt | 30 ++++++--- .../chat/simplex/common/platform/Files.kt | 19 +++++- .../simplex/common/views/chat/ChatView.kt | 7 +- .../simplex/common/views/chat/ComposeView.kt | 67 +++++++++++++------ .../common/views/chat/item/CIFileView.kt | 18 +++-- .../common/views/chat/item/CIImageView.kt | 25 +++++-- .../common/views/chat/item/CIVIdeoView.kt | 18 ++++- .../common/views/chat/item/CIVoiceView.kt | 23 +++++-- .../common/views/chat/item/ChatItemView.kt | 41 ++++++++---- .../common/views/helpers/AlertManager.kt | 19 ++++++ .../simplex/common/views/helpers/Utils.kt | 34 +++++++--- .../commonMain/resources/MR/base/strings.xml | 2 + .../common/model/NtfManager.desktop.kt | 2 +- .../simplex/common/platform/Files.desktop.kt | 2 + .../views/chat/item/CIImageView.desktop.kt | 2 - .../views/chat/item/ChatItemView.desktop.kt | 36 +++++++--- .../common/views/helpers/Utils.desktop.kt | 23 +++++-- 20 files changed, 334 insertions(+), 97 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt index 161bc51e61..dfc8c1d4e7 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -23,6 +23,8 @@ actual val agentDatabaseFileName: String = "files_agent.db" actual val databaseExportDir: File = androidAppContext.cacheDir +actual val remoteHostsDir: File = File(tmpDir.absolutePath + File.separator + "remote_hosts") + actual fun desktopOpenDatabaseDir() {} @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index f2c2f393ab..5c7273ecc8 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -167,7 +167,7 @@ actual fun getAppFileUri(fileName: String): URI = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI() // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -actual fun getLoadedImage(file: CIFile?): Pair? { +actual suspend fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) return if (filePath != null && file != null) { try { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index c3ec33e0c2..86e5072307 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1,7 +1,8 @@ package chat.simplex.common.model -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* @@ -596,6 +597,8 @@ object ChatModel { } terminalItems.add(item) } + + fun connectedToRemote(): Boolean = currentRemoteHost.value != null } enum class ChatType(val type: String) { @@ -2224,7 +2227,7 @@ enum class MREmojiChar(val value: String) { } @Serializable -class CIFile( +data class CIFile( val fileId: Long, val fileName: String, val fileSize: Long, @@ -2268,6 +2271,39 @@ class CIFile( is CIFileStatus.Invalid -> null } + /** + * DO NOT CALL this function in compose scope, [LaunchedEffect], [DisposableEffect] and so on. Only with [withBGApi] or [runBlocking]. + * Otherwise, it will be canceled when moving to another screen/item/view, etc + * */ + suspend fun loadRemoteFile(allowToShowAlert: Boolean): Boolean { + val rh = chatModel.currentRemoteHost.value + val user = chatModel.currentUser.value + if (rh == null || user == null || fileSource == null || !loaded) return false + if (getLoadedFilePath(this) != null) return true + if (cachedRemoteFileRequests.contains(fileSource)) return false + + val rf = RemoteFile( + userId = user.userId, + fileId = fileId, + sent = fileStatus.sent, + fileSource = fileSource + ) + cachedRemoteFileRequests.add(fileSource) + val showAlert = fileSize > 5_000_000 && allowToShowAlert + if (showAlert) { + AlertManager.shared.showAlertMsgWithProgress( + title = generalGetString(MR.strings.loading_remote_file_title), + text = generalGetString(MR.strings.loading_remote_file_desc) + ) + } + val res = chatModel.controller.getRemoteFile(rh.remoteHostId, rf) + cachedRemoteFileRequests.remove(fileSource) + if (showAlert) { + AlertManager.shared.hideAlert() + } + return res + } + companion object { fun getSample( fileId: Long = 1, @@ -2277,6 +2313,8 @@ class CIFile( fileStatus: CIFileStatus = CIFileStatus.RcvComplete ): CIFile = CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) + + val cachedRemoteFileRequests = SnapshotStateList() } } @@ -2308,6 +2346,8 @@ data class CryptoFile( companion object { fun plain(f: String): CryptoFile = CryptoFile(f, null) + + fun desktopPlain(f: URI): CryptoFile = CryptoFile(f.rawPath, null) } } @@ -2370,6 +2410,21 @@ sealed class CIFileStatus { @Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus() @Serializable @SerialName("rcvError") object RcvError: CIFileStatus() @Serializable @SerialName("invalid") class Invalid(val text: String): CIFileStatus() + + val sent: Boolean get() = when (this) { + is SndStored -> true + is SndTransfer -> true + is SndComplete -> true + is SndCancelled -> true + is SndError -> true + is RcvInvitation -> false + is RcvAccepted -> false + is RcvTransfer -> false + is RcvComplete -> false + is RcvCancelled -> false + is RcvError -> false + is Invalid -> false + } } @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 98c48dbfb4..0d3b16fa80 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -24,6 +24,7 @@ import kotlinx.serialization.* import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.* +import java.io.File import java.util.Date typealias ChatCtrl = Long @@ -339,6 +340,9 @@ object ChatController { apiSetNetworkConfig(getNetCfg()) apiSetTempFolder(coreTmpDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath) + if (appPlatform.isDesktop) { + apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) + } apiSetXFTPConfig(getXFTPCfg()) apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() @@ -418,14 +422,14 @@ object ChatController { } } - suspend fun sendCmd(cmd: CC): CR { + suspend fun sendCmd(cmd: CC, customRhId: Long? = null): CR { val ctrl = ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") - val rhId = chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 + val rhId = customRhId?.toInt() ?: chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 val json = if (rhId == -1) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId, c) val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") @@ -559,6 +563,12 @@ object ChatController { throw Error("failed to set files folder: ${r.responseType} ${r.details}") } + private suspend fun apiSetRemoteHostsFolder(remoteHostsFolder: String) { + val r = sendCmd(CC.SetRemoteHostsFolder(remoteHostsFolder)) + if (r is CR.CmdOk) return + throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}") + } + suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) { val r = sendCmd(CC.ApiSetXFTPConfig(cfg)) if (r is CR.CmdOk) return @@ -609,9 +619,9 @@ object ChatController { return null } - suspend fun apiSendMessage(type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + suspend fun apiSendMessage(rhId: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) - val r = sendCmd(cmd) + val r = sendCmd(cmd, rhId) return when (r) { is CR.NewChatItem -> r.chatItem else -> { @@ -1142,8 +1152,9 @@ object ChatController { return false } - suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { - val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline)) + suspend fun apiReceiveFile(rhId: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { + // -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected + val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline), rhId ?: -1) return when (r) { is CR.RcvFileAccepted -> r.chatItem is CR.RcvFileAcceptedSndCancelled -> { @@ -1868,7 +1879,7 @@ object ChatController { } suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { - val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto) + val chatItem = apiReceiveFile(rhId, fileId, encrypted = encrypted, auto = auto) if (chatItem != null) { chatItemSimpleUpdate(rhId, user, chatItem) } @@ -2035,6 +2046,7 @@ sealed class CC { class ApiStopChat: CC() class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() + class SetRemoteHostsFolder(val remoteHostsFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() class ApiSetEncryptLocalFiles(val enable: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() @@ -2161,6 +2173,7 @@ sealed class CC { is ApiStopChat -> "/_stop" is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" + is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" @@ -2259,7 +2272,7 @@ sealed class CC { is DeleteRemoteHost -> "/delete remote host $remoteHostId" is StoreRemoteFile -> "/store remote file $remoteHostId " + - (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)}") + + (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)} ") + localPath is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}" is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation" @@ -2290,6 +2303,7 @@ sealed class CC { is ApiStopChat -> "apiStopChat" is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" + is SetRemoteHostsFolder -> "setRemoteHostsFolder" is ApiSetXFTPConfig -> "apiSetXFTPConfig" is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" is ApiExportArchive -> "apiExportArchive" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 71a9f204f8..877356e43a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -24,6 +24,8 @@ expect val agentDatabaseFileName: String * */ expect val databaseExportDir: File +expect val remoteHostsDir: File + expect fun desktopOpenDatabaseDir() fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { @@ -59,14 +61,20 @@ fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) { } fun getAppFilePath(fileName: String): String { - return appFilesDir.absolutePath + File.separator + fileName + val rh = chatModel.currentRemoteHost.value + val s = File.separator + return if (rh == null) { + appFilesDir.absolutePath + s + fileName + } else { + remoteHostsDir.absolutePath + s + rh.storePath + s + "simplex_v1_files" + s + fileName + } } fun getLoadedFilePath(file: CIFile?): String? { val f = file?.fileSource?.filePath return if (f != null && file.loaded) { val filePath = getAppFilePath(f) - if (File(filePath).exists()) filePath else null + if (fileReady(file, filePath)) filePath else null } else { null } @@ -76,12 +84,17 @@ fun getLoadedFileSource(file: CIFile?): CryptoFile? { val f = file?.fileSource?.filePath return if (f != null && file.loaded) { val filePath = getAppFilePath(f) - if (File(filePath).exists()) file.fileSource else null + if (fileReady(file, filePath)) file.fileSource else null } else { null } } +private fun fileReady(file: CIFile, filePath: String) = + File(filePath).exists() && + !CIFile.cachedRemoteFileRequests.contains(file.fileSource) + && File(filePath).length() >= file.fileSize + /** * [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function * */ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 527d68a523..7097c77d1a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -499,6 +499,7 @@ fun ChatLayout( enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) }, onImage = { + // TODO: file is not saved anywhere?! val tmpFile = File.createTempFile("image", ".bmp", tmpDir) tmpFile.deleteOnExit() chatModel.filesToDelete.add(tmpFile) @@ -1300,7 +1301,7 @@ private fun providerForGallery( scrollTo: (Int) -> Unit ): ImageGalleryProvider { fun canShowMedia(item: ChatItem): Boolean = - (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(item.file) != null) + (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && (getLoadedFilePath(item.file) != null || chatModel.connectedToRemote())) fun item(skipInternalIndex: Int, initialChatId: Long): Pair? { var processedInternalIndex = -skipInternalIndex.sign @@ -1327,7 +1328,7 @@ private fun providerForGallery( val item = item(internalIndex, initialChatId)?.second ?: return null return when (item.content.msgContent) { is MsgContent.MCImage -> { - val res = getLoadedImage(item.file) + val res = runBlocking { getLoadedImage(item.file) } val filePath = getLoadedFilePath(item.file) if (res != null && filePath != null) { val (imageBitmap: ImageBitmap, data: ByteArray) = res @@ -1335,7 +1336,7 @@ private fun providerForGallery( } else null } is MsgContent.MCVideo -> { - val filePath = getLoadedFilePath(item.file) + val filePath = if (chatModel.connectedToRemote() && item.file?.loaded == true) getAppFilePath(item.file.fileName) else getLoadedFilePath(item.file) if (filePath != null) { val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 959ded42bf..a9b7014d51 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -15,6 +15,8 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.Indigo import chat.simplex.common.ui.theme.isSystemInDarkTheme @@ -349,8 +351,9 @@ fun ComposeView( } } - suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(rhId: Long?, cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { val aChatItem = chatModel.controller.apiSendMessage( + rhId = rhId, type = cInfo.chatType, id = cInfo.apiId, file = file, @@ -447,15 +450,23 @@ fun ComposeView( } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() + val remoteHost = chatModel.currentRemoteHost.value when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) is ComposePreview.MediaPreview -> { preview.content.forEachIndexed { index, it -> + val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() val file = when (it) { - is UploadContent.SimpleImage -> saveImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) - is UploadContent.AnimatedImage -> saveAnimImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) - is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false) + is UploadContent.SimpleImage -> + if (remoteHost == null) saveImage(it.uri, encrypted = encrypted) + else desktopSaveImageInTmp(it.uri) + is UploadContent.AnimatedImage -> + if (remoteHost == null) saveAnimImage(it.uri, encrypted = encrypted) + else CryptoFile.desktopPlain(it.uri) + is UploadContent.Video -> + if (remoteHost == null) saveFileFromUri(it.uri, encrypted = false) + else CryptoFile.desktopPlain(it.uri) } if (file != null) { files.add(file) @@ -470,22 +481,32 @@ fun ComposeView( is ComposePreview.VoicePreview -> { val tmpFile = File(preview.voice) AudioPlayer.stop(tmpFile.absolutePath) - val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) - files.add(withContext(Dispatchers.IO) { - if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { - val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) - tmpFile.delete() - CryptoFile(actualFile.name, args) - } else { - Files.move(tmpFile.toPath(), actualFile.toPath()) - CryptoFile.plain(actualFile.name) - } - }) - deleteUnusedFiles() + if (remoteHost == null) { + val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) + files.add(withContext(Dispatchers.IO) { + if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { + val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) + tmpFile.delete() + CryptoFile(actualFile.name, args) + } else { + Files.move(tmpFile.toPath(), actualFile.toPath()) + CryptoFile.plain(actualFile.name) + } + }) + deleteUnusedFiles() + } else { + files.add(CryptoFile.plain(tmpFile.absolutePath)) + // It will be deleted on JVM shutdown or next start (if the app crashes unexpectedly) + filesToDelete.remove(tmpFile) + } msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) } is ComposePreview.FilePreview -> { - val file = saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + val file = if (remoteHost == null) { + saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + } else { + CryptoFile.desktopPlain(preview.uri) + } if (file != null) { files.add((file)) msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) @@ -499,7 +520,15 @@ fun ComposeView( sent = null msgs.forEachIndexed { index, content -> if (index > 0) delay(100) - sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index), + var file = files.getOrNull(index) + if (remoteHost != null && file != null) { + file = controller.storeRemoteFile( + rhId = remoteHost.remoteHostId, + storeEncrypted = if (content is MsgContent.MCVideo) false else null, + localPath = file.filePath + ) + } + sent = send(remoteHost?.remoteHostId, cInfo, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, ttl = ttl ) @@ -509,7 +538,7 @@ fun ComposeView( cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { - sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) + sent = send(remoteHost?.remoteHostId, cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } clearState(live) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 57dcd16cb9..0d439f1235 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -94,13 +94,19 @@ fun CIFileView( ) } is CIFileStatus.RcvComplete -> { - val filePath = getLoadedFilePath(file) - if (filePath != null) { - withApi { - saveFileLauncher.launch(file.fileName) + withBGApi { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file.loadRemoteFile(true) + filePath = getLoadedFilePath(file) + } + if (filePath != null) { + withApi { + saveFileLauncher.launch(file.fileName) + } + } else { + showToast(generalGetString(MR.strings.file_not_found)) } - } else { - showToast(generalGetString(MR.strings.file_not_found)) } } else -> {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 23d1f1d0cc..8b0b2debca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -22,6 +22,9 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.runBlocking import java.io.File import java.net.URI @@ -134,7 +137,7 @@ fun CIImageView( return false } - fun imageAndFilePath(file: CIFile?): Triple? { + suspend fun imageAndFilePath(file: CIFile?): Triple? { val res = getLoadedImage(file) if (res != null) { val (imageBitmap: ImageBitmap, data: ByteArray) = res @@ -148,9 +151,23 @@ fun CIImageView( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val res = remember(file) { imageAndFilePath(file) } - if (res != null) { - val (imageBitmap, data, _) = res + val res: MutableState?> = remember { + mutableStateOf( + if (chatModel.connectedToRemote()) null else runBlocking { imageAndFilePath(file) } + ) + } + if (chatModel.connectedToRemote()) { + LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { + withBGApi { + if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { + res.value = imageAndFilePath(file) + } + } + } + } + val loaded = res.value + if (loaded != null) { + val (imageBitmap, data, _) = loaded SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) }) } else { imageView(base64ToBitmap(image), onClick = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index 996dc819fe..04ec307358 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -21,6 +21,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.flow.* import java.io.File import java.net.URI @@ -37,10 +38,21 @@ fun CIVideoView( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val filePath = remember(file) { getLoadedFilePath(file) } val preview = remember(image) { base64ToBitmap(image) } - if (file != null && filePath != null) { - val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) } + val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } + if (chatModel.connectedToRemote()) { + LaunchedEffect(file) { + withBGApi { + if (file != null && file.loaded && getLoadedFilePath(file) == null) { + file.loadRemoteFile(false) + filePath.value = getLoadedFilePath(file) + } + } + } + } + val f = filePath.value + if (file != null && f != null) { + val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) } val view = LocalMultiplatformView() VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = { hideKeyboard(view) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 941bc315b6..0c8487458f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.* // TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901 @@ -44,16 +44,25 @@ fun CIVoiceView( ) { if (file != null) { val f = file.fileSource?.filePath - val fileSource = remember(f, file.fileStatus) { getLoadedFileSource(file) } + val fileSource = remember(f, file.fileStatus, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFileSource(file)) } var brokenAudio by rememberSaveable(f) { mutableStateOf(false) } val audioPlaying = rememberSaveable(f) { mutableStateOf(false) } val progress = rememberSaveable(f) { mutableStateOf(0) } val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) } - val play = { - if (fileSource != null) { - AudioPlayer.play(fileSource, audioPlaying, progress, duration, true) - brokenAudio = !audioPlaying.value + val play: () -> Unit = { + val playIfExists = { + if (fileSource.value != null) { + AudioPlayer.play(fileSource.value!!, audioPlaying, progress, duration, true) + brokenAudio = !audioPlaying.value + } } + if (chatModel.connectedToRemote() && fileSource.value == null) { + withBGApi { + file.loadRemoteFile(true) + fileSource.value = getLoadedFileSource(file) + playIfExists() + } + } else playIfExists() } val pause = { AudioPlayer.pause(audioPlaying, progress) @@ -68,7 +77,7 @@ fun CIVoiceView( } } VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) { - AudioPlayer.seekTo(it, progress, fileSource?.filePath) + AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 095723a18e..17e2fe0447 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -194,19 +194,34 @@ fun ChatItemView( }) } val clipboard = LocalClipboardManager.current - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val fileSource = getLoadedFileSource(cItem.file) - when { - fileSource != null -> shareFile(cItem.text, fileSource) - else -> clipboard.shareText(cItem.content.text) - } - showMenu.value = false - }) - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { + val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } + val copyAndShareAllowed = cItem.file == null || !chatModel.connectedToRemote() || getLoadedFilePath(cItem.file) != null || !cachedRemoteReqs.contains(cItem.file.fileSource) + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + var fileSource = getLoadedFileSource(cItem.file) + val shareIfExists = { + when (val f = fileSource) { + null -> clipboard.shareText(cItem.content.text) + else -> shareFile(cItem.text, f) + } + showMenu.value = false + } + if (chatModel.connectedToRemote() && fileSource == null) { + withBGApi { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + shareIfExists() + } + } else shareIfExists() + }) + } + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + } + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && !cachedRemoteReqs.contains(cItem.file?.fileSource)))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 35d5b8b3ef..fa9c89384d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -188,6 +188,25 @@ class AlertManager { ) } } + + fun showAlertMsgWithProgress( + title: String, + text: String? = null + ) { + showAlert { + AlertDialog( + onDismissRequest = this::hideAlert, + title = alertTitle(title), + text = alertText(text), + buttons = { + Box(Modifier.fillMaxWidth().height(72.dp).padding(bottom = DEFAULT_PADDING * 2), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) + } + } + ) + } + } + fun showAlertMsg( title: StringResource, text: StringResource? = null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 5e64de2c54..7128d2185c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -67,7 +67,7 @@ const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB expect fun getAppFileUri(fileName: String): URI // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -expect fun getLoadedImage(file: CIFile?): Pair? +expect suspend fun getLoadedImage(file: CIFile?): Pair? expect fun getFileName(uri: URI): String? @@ -106,7 +106,7 @@ fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? { return try { val ext = if (image.hasAlpha()) "png" else "jpg" val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) - val destFileName = generateNewFileName("IMG", ext) + val destFileName = generateNewFileName("IMG", ext, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray()) @@ -124,6 +124,24 @@ fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? { } } +fun desktopSaveImageInTmp(uri: URI): CryptoFile? { + val image = getBitmapFromUri(uri) ?: return null + return try { + val ext = if (image.hasAlpha()) "png" else "jpg" + val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) + val destFileName = generateNewFileName("IMG", ext, tmpDir) + val destFile = File(tmpDir, destFileName) + val output = FileOutputStream(destFile) + dataResized.writeTo(output) + output.flush() + output.close() + CryptoFile.plain(destFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Util.kt desktopSaveImageInTmp error: ${e.stackTraceToString()}") + null + } +} + fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { return try { val filename = getFileName(uri)?.lowercase() @@ -134,7 +152,7 @@ fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { } // Just in case the image has a strange extension if (ext.length < 3 || ext.length > 4) ext = "gif" - val destFileName = generateNewFileName("IMG", ext) + val destFileName = generateNewFileName("IMG", ext, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readBytes() ?: return null) @@ -156,7 +174,7 @@ fun saveFileFromUri(uri: URI, encrypted: Boolean, withAlertOnException: Boolean val inputStream = uri.inputStream() val fileToSave = getFileName(uri) return if (inputStream != null && fileToSave != null) { - val destFileName = uniqueCombine(fileToSave) + val destFileName = uniqueCombine(fileToSave, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { createTmpFileAndDelete { tmpFile -> @@ -193,21 +211,21 @@ fun createTmpFileAndDelete(onCreated: (File) -> T): T { } } -fun generateNewFileName(prefix: String, ext: String): String { +fun generateNewFileName(prefix: String, ext: String, dir: File): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) sdf.timeZone = TimeZone.getTimeZone("GMT") val timestamp = sdf.format(Date()) - return uniqueCombine("${prefix}_$timestamp.$ext") + return uniqueCombine("${prefix}_$timestamp.$ext", dir) } -fun uniqueCombine(fileName: String): String { +fun uniqueCombine(fileName: String, dir: File): String { val orig = File(fileName) val name = orig.nameWithoutExtension val ext = orig.extension fun tryCombine(n: Int): String { val suffix = if (n == 0) "" else "_$n" val f = "$name$suffix.$ext" - return if (File(getAppFilePath(f)).exists()) tryCombine(n + 1) else f + return if (File(dir, f).exists()) tryCombine(n + 1) else f } return tryCombine(0) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b2f3e2f635..eb3b9207b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -350,6 +350,8 @@ File saved File not found Error saving file + Loading the file + Please, wait while the file is being loaded from the linked mobile Voice message diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt index 94e985328e..cb34bdb3b0 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt @@ -113,7 +113,7 @@ object NtfManager { private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) { tmpDir.mkdir() - val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png")) + val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png", tmpDir)) try { ImageIO.write(icon.toAwtImage(), "PNG", newFile.outputStream()) newFile.absolutePath diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 9042a62830..0f7c131862 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -21,6 +21,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir +actual val remoteHostsDir: File = File(dataDir.absolutePath + File.separator + "remote_hosts") + actual fun desktopOpenDatabaseDir() { if (Desktop.isDesktopSupported()) { try { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 711e09267d..6da2078567 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -2,12 +2,10 @@ package chat.simplex.common.views.chat.item import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import chat.simplex.common.model.CIFile import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.ModalManager -import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index f602dd577c..91efdf7908 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -34,35 +34,51 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = { - when (cItem.content.msgContent) { - is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } - else -> {} + val saveIfExists = { + when (cItem.content.msgContent) { + is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } + else -> {} + } + showMenu.value = false } - showMenu.value = false + var fileSource = getLoadedFileSource(cItem.file) + if (chatModel.connectedToRemote() && fileSource == null) { + withBGApi { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + saveIfExists() + } + } else saveIfExists() }) } -actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { - val fileSource = getLoadedFileSource(cItem.file) +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withBGApi { + var fileSource = getLoadedFileSource(cItem.file) + if (chatModel.connectedToRemote() && fileSource == null) { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + } + if (fileSource != null) { val filePath: String = if (fileSource.cryptoArgs != null) { val tmpFile = File(tmpDir, fileSource.filePath) tmpFile.deleteOnExit() try { - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs ?: return@withBGApi, tmpFile.absolutePath) } catch (e: Exception) { Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) - return + return@withBGApi } tmpFile.absolutePath } else { getAppFilePath(fileSource.filePath) } - when { + when { desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\"")) else -> clipboard.setText(AnnotatedString(filePath)) } } else { clipboard.setText(AnnotatedString(cItem.content.text)) } -} + showToast(MR.strings.copied.localized()) +}.run {} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 7478e22a43..9413cbb404 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -5,8 +5,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density -import chat.simplex.common.model.CIFile -import chat.simplex.common.model.readCryptoFile +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState import chat.simplex.res.MR @@ -88,11 +87,21 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat AnnotatedString(text) } -actual fun getAppFileUri(fileName: String): URI = - URI(appFilesDir.toURI().toString() + "/" + fileName) +actual fun getAppFileUri(fileName: String): URI { + val rh = chatModel.currentRemoteHost.value + return if (rh == null) { + URI(appFilesDir.toURI().toString() + "/" + fileName) + } else { + URI(dataDir.absolutePath + "/remote_hosts/" + rh.storePath + "/simplex_v1_files/" + fileName) + } +} -actual fun getLoadedImage(file: CIFile?): Pair? { - val filePath = getLoadedFilePath(file) +actual suspend fun getLoadedImage(file: CIFile?): Pair? { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file?.loadRemoteFile(false) + filePath = getLoadedFilePath(file) + } return if (filePath != null) { try { val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() @@ -141,7 +150,7 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return if (file != null) { try { val ext = if (asPng) "png" else "jpg" - val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext)) + val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(getAppFilePath("")))) // LALAL FILE IS EMPTY ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream()) newFile From 96e000e3ea3da2792ad688149e9a58a07436aed5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 20:28:55 +0000 Subject: [PATCH 211/294] ios: add user-defined device name for remote desktop connection --- apps/ios/SimpleX (iOS).entitlements | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 80e4adf2c2..c78a7cb941 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -19,6 +19,8 @@ $(AppIdentifierPrefix)chat.simplex.app com.apple.developer.networking.multicast - + + com.apple.developer.device-information.user-assigned-device-name + From f9e5a56e1a363bbd42611c943373eb0feba115ea Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 18 Nov 2023 22:20:22 +0000 Subject: [PATCH 212/294] ios: terminate session on network failure, add description for local network access --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +++- apps/ios/SimpleX.xcodeproj/project.pbxproj | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f1aba91263..9e4cc7cd02 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1729,7 +1729,9 @@ func processReceivedMsg(_ res: ChatResponse) async { m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state) } case .remoteCtrlStopped: - await MainActor.run { + // This delay is needed to cancel the session that fails on network failure, + // e.g. when user did not grant permission to access local network yet. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { switchToLocalSession() } default: diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 62db4e43ec..e98704d309 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1502,6 +1502,7 @@ INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -1544,6 +1545,7 @@ INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; From b164cc2fa6e1a9f11dd31e46615cb9ec6a507daa Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 19 Nov 2023 08:31:29 +0800 Subject: [PATCH 213/294] nix: fix lib:support for armv7a (#3394) --- flake.nix | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index b56756922f..9c6bb3a226 100644 --- a/flake.nix +++ b/flake.nix @@ -253,7 +253,17 @@ # we also do not want to have any dependencies listed (especially no rts!) enableStatic = false; - setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; + # This used to work with 8.10.7... + # setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; + # ... but now with 9.6+ + # we have to do the -shared thing by hand. + postBuild = '' + armv7a-unknown-linux-androideabi-ghc -shared -o libsupport.so \ + -optl-Wl,-u,setLineBuffering \ + -optl-Wl,-u,pipe_std_to_socket \ + dist/build/*.a + ''; + postInstall = '' mkdir -p $out/_pkg From 8f0538e756b399bc3f8b063c62d82ca926f0bd3b Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 19 Nov 2023 09:07:42 +0800 Subject: [PATCH 214/294] android: UI for remote connections (#3395) * android: UI for remote connections * camera permissions * eol --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../common/platform/AppCommon.android.kt | 2 + .../common/platform/RecAndPlay.android.kt | 2 +- .../common/views/chat/ScanCodeView.android.kt | 16 - .../common/views/helpers/Utils.android.kt | 2 +- .../views/newchat/QRCodeScanner.android.kt | 6 + .../chat/simplex/common/model/ChatModel.kt | 27 +- .../chat/simplex/common/model/SimpleXAPI.kt | 116 ++++- .../chat/simplex/common/platform/AppCommon.kt | 2 + .../simplex/common/views/chat/ScanCodeView.kt | 5 +- .../common/views/chatlist/UserPicker.kt | 21 + .../simplex/common/views/helpers/Utils.kt | 8 + .../common/views/remote/ConnectDesktopView.kt | 472 ++++++++++++++++++ .../common/views/remote/ConnectMobileView.kt | 2 +- .../common/views/usersettings/SettingsView.kt | 3 + .../commonMain/resources/MR/base/strings.xml | 26 + .../common/platform/AppCommon.desktop.kt | 4 + .../common/views/chat/ScanCodeView.desktop.kt | 8 - 17 files changed, 669 insertions(+), 53 deletions(-) delete mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt index 192f3dcc29..90b18bde93 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt @@ -16,6 +16,8 @@ import kotlin.random.Random actual val appPlatform = AppPlatform.ANDROID +actual val deviceName = android.os.Build.MODEL + var isAppOnForeground: Boolean = false @Suppress("ConstantLocale") diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index f99dea77ca..5b0d3c778f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -38,7 +38,7 @@ actual class RecorderNative: RecorderInterface { rec.setAudioSamplingRate(16000) rec.setAudioEncodingBitRate(32000) rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING) - val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_"), ".tmp", tmpDir) + val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_", tmpDir), ".tmp", tmpDir) fileToSave.deleteOnExit() val path = fileToSave.absolutePath filePath = path diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt deleted file mode 100644 index 79361dc073..0000000000 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt +++ /dev/null @@ -1,16 +0,0 @@ -package chat.simplex.common.views.chat - -import android.Manifest -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.views.chat.ScanCodeLayout -import com.google.accompanist.permissions.rememberPermissionState - -@Composable -actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - ScanCodeLayout(verifyCode, close) -} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 5c7273ecc8..d244294763 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -295,7 +295,7 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return try { val ext = if (asPng) "png" else "jpg" tmpDir.mkdir() - return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply { + return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext, tmpDir)).apply { outputStream().use { out -> image.asAndroidBitmap().compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out) out.flush() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt index 7fb6445d57..e7453ce20a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.newchat +import android.Manifest import android.annotation.SuppressLint import android.util.Log import android.view.ViewGroup @@ -19,6 +20,7 @@ import boofcv.android.ConvertCameraImage import boofcv.factory.fiducial.FactoryFiducial import boofcv.struct.image.GrayU8 import chat.simplex.common.platform.TAG +import com.google.accompanist.permissions.rememberPermissionState import com.google.common.util.concurrent.ListenableFuture import java.util.concurrent.* @@ -26,6 +28,10 @@ import java.util.concurrent.* @Composable actual fun QRCodeScanner(onBarcode: (String) -> Unit) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var preview by remember { mutableStateOf(null) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 86e5072307..af946be3cd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -27,6 +27,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.io.File import java.net.URI +import java.net.URLDecoder import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* @@ -111,6 +112,7 @@ object ChatModel { val remoteHosts = mutableStateListOf() val currentRemoteHost = mutableStateOf(null) val newRemoteHostPairing = mutableStateOf?>(null) + val remoteCtrlSession = mutableStateOf(null) fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value @@ -598,7 +600,7 @@ object ChatModel { terminalItems.add(item) } - fun connectedToRemote(): Boolean = currentRemoteHost.value != null + fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } enum class ChatType(val type: String) { @@ -2347,7 +2349,7 @@ data class CryptoFile( companion object { fun plain(f: String): CryptoFile = CryptoFile(f, null) - fun desktopPlain(f: URI): CryptoFile = CryptoFile(f.rawPath, null) + fun desktopPlain(f: URI): CryptoFile = CryptoFile(URLDecoder.decode(f.rawPath, "UTF-8"), null) } } @@ -2907,8 +2909,18 @@ enum class NotificationPreviewMode { data class RemoteCtrlSession( val ctrlAppInfo: CtrlAppInfo, val appVersion: String, - val sessionState: RemoteCtrlSessionState -) + val sessionState: UIRemoteCtrlSessionState +) { + val active: Boolean + get () = sessionState is UIRemoteCtrlSessionState.Connected + + val sessionCode: String? + get() = when (val s = sessionState) { + is UIRemoteCtrlSessionState.PendingConfirmation -> s.sessionCode + is UIRemoteCtrlSessionState.Connected -> s.sessionCode + else -> null + } +} @Serializable sealed class RemoteCtrlSessionState { @@ -2917,3 +2929,10 @@ sealed class RemoteCtrlSessionState { @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val sessionCode: String): RemoteCtrlSessionState() @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState() } + +sealed class UIRemoteCtrlSessionState { + @Serializable @SerialName("starting") object Starting: UIRemoteCtrlSessionState() + @Serializable @SerialName("connecting") data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() + @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() + @Serializable @SerialName("connected") data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 0d3b16fa80..253a9fb163 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -24,7 +24,6 @@ import kotlinx.serialization.* import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.* -import java.io.File import java.util.Date typealias ChatCtrl = Long @@ -167,7 +166,11 @@ class AppPreferences { val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null) val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0) val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300) - val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, "Desktop") + val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, deviceName) + + val confirmRemoteSessions = mkBoolPreference(SHARED_PREFS_CONFIRM_REMOTE_SESSIONS, false) + val connectRemoteViaMulticast = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST, false) + val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -309,6 +312,9 @@ class AppPreferences { private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode" private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime" private const val SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS = "DeviceNameForRemoteAccess" + private const val SHARED_PREFS_CONFIRM_REMOTE_SESSIONS = "ConfirmRemoteSessions" + private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast" + private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" } } @@ -1430,18 +1436,23 @@ object ChatController { suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCommandOkResp(CC.GetRemoteFile(rhId, file)) - suspend fun connectRemoteCtrl(invitation: String): SomeRemoteCtrl? { - val r = sendCmd(CC.ConnectRemoteCtrl(invitation)) - if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) - apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) - return null + suspend fun connectRemoteCtrl(desktopAddress: String): Pair { + val r = sendCmd(CC.ConnectRemoteCtrl(desktopAddress)) + if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null + else if (r is CR.ChatCmdError) return null to r + else throw Exception("connectRemoteCtrl error: ${r.responseType} ${r.details}") } suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(CC.FindKnownRemoteCtrl()) - suspend fun confirmRemoteCtrl(rhId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rhId)) + suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rcId)) - suspend fun verifyRemoteCtrlSession(sessionCode: String): Boolean = sendCommandOkResp(CC.VerifyRemoteCtrlSession(sessionCode)) + suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { + val r = sendCmd(CC.VerifyRemoteCtrlSession(sessionCode)) + if (r is CR.RemoteCtrlConnected) return r.remoteCtrl + apiErrorAlert("verifyRemoteCtrlSession", generalGetString(MR.strings.error_alert_title), r) + return null + } suspend fun listRemoteCtrls(): List? { val r = sendCmd(CC.ListRemoteCtrls()) @@ -1843,6 +1854,22 @@ object ChatController { chatModel.newRemoteHostPairing.value = null switchUIRemoteHost(null) } + is CR.RemoteCtrlFound -> { + // TODO multicast + Log.d(TAG, "RemoteCtrlFound: ${r.remoteCtrl}") + } + is CR.RemoteCtrlSessionCode -> { + val state = UIRemoteCtrlSessionState.PendingConfirmation(remoteCtrl_ = r.remoteCtrl_, sessionCode = r.sessionCode) + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) + } + is CR.RemoteCtrlConnected -> { + // TODO currently it is returned in response to command, so it is redundant + val state = UIRemoteCtrlSessionState.Connected(remoteCtrl = r.remoteCtrl, sessionCode = chatModel.remoteCtrlSession.value?.sessionCode ?: "") + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) + } + is CR.RemoteCtrlStopped -> { + switchToLocalSession() + } else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -1866,6 +1893,23 @@ object ChatController { } } + fun switchToLocalSession() { + val m = chatModel + m.remoteCtrlSession.value = null + withBGApi { + val users = listUsers() + m.users.clear() + m.users.addAll(users) + getUserChatData() + val statuses = apiGetNetworkStatuses() + if (statuses != null) { + chatModel.networkStatuses.clear() + val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() + chatModel.networkStatuses.putAll(ss) + } + } + } + private fun activeUser(rhId: Long?, user: UserLike): Boolean = rhId == chatModel.currentRemoteHost.value?.remoteHostId && user.userId == chatModel.currentUser.value?.userId @@ -3474,7 +3518,10 @@ data class RemoteCtrlInfo ( val remoteCtrlId: Long, val ctrlDeviceName: String, val sessionState: RemoteCtrlSessionState? -) +) { + val deviceViewName: String + get() = ctrlDeviceName.ifEmpty { remoteCtrlId.toString() } +} @Serializable data class RemoteHostInfo( @@ -4558,6 +4605,7 @@ sealed class AgentErrorType { is SMP -> "SMP ${smpErr.string}" // is NTF -> "NTF ${ntfErr.string}" is XFTP -> "XFTP ${xftpErr.string}" + is RCP -> "RCP ${rcpErr.string}" is BROKER -> "BROKER ${brokerErr.string}" is AGENT -> "AGENT ${agentErr.string}" is INTERNAL -> "INTERNAL $internalErr" @@ -4568,6 +4616,7 @@ sealed class AgentErrorType { @Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType() // @Serializable @SerialName("NTF") class NTF(val ntfErr: SMPErrorType): AgentErrorType() @Serializable @SerialName("XFTP") class XFTP(val xftpErr: XFTPErrorType): AgentErrorType() + @Serializable @SerialName("XFTP") class RCP(val rcpErr: RCErrorType): AgentErrorType() @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() @@ -4738,6 +4787,38 @@ sealed class XFTPErrorType { @Serializable @SerialName("INTERNAL") object INTERNAL: XFTPErrorType() } +@Serializable +sealed class RCErrorType { + val string: String get() = when (this) { + is INTERNAL -> "INTERNAL $internalErr" + is IDENTITY -> "IDENTITY" + is NO_LOCAL_ADDRESS -> "NO_LOCAL_ADDRESS" + is TLS_START_FAILED -> "TLS_START_FAILED" + is EXCEPTION -> "EXCEPTION $EXCEPTION" + is CTRL_AUTH -> "CTRL_AUTH" + is CTRL_NOT_FOUND -> "CTRL_NOT_FOUND" + is CTRL_ERROR -> "CTRL_ERROR $ctrlErr" + is VERSION -> "VERSION" + is ENCRYPT -> "ENCRYPT" + is DECRYPT -> "DECRYPT" + is BLOCK_SIZE -> "BLOCK_SIZE" + is SYNTAX -> "SYNTAX $syntaxErr" + } + @Serializable @SerialName("internal") data class INTERNAL(val internalErr: String): RCErrorType() + @Serializable @SerialName("identity") object IDENTITY: RCErrorType() + @Serializable @SerialName("noLocalAddress") object NO_LOCAL_ADDRESS: RCErrorType() + @Serializable @SerialName("tlsStartFailed") object TLS_START_FAILED: RCErrorType() + @Serializable @SerialName("exception") data class EXCEPTION(val exception: String): RCErrorType() + @Serializable @SerialName("ctrlAuth") object CTRL_AUTH: RCErrorType() + @Serializable @SerialName("ctrlNotFound") object CTRL_NOT_FOUND: RCErrorType() + @Serializable @SerialName("ctrlError") data class CTRL_ERROR(val ctrlErr: String): RCErrorType() + @Serializable @SerialName("version") object VERSION: RCErrorType() + @Serializable @SerialName("encrypt") object ENCRYPT: RCErrorType() + @Serializable @SerialName("decrypt") object DECRYPT: RCErrorType() + @Serializable @SerialName("blockSize") object BLOCK_SIZE: RCErrorType() + @Serializable @SerialName("syntax") data class SYNTAX(val syntaxErr: String): RCErrorType() +} + @Serializable sealed class ArchiveError { val string: String get() = when (this) { @@ -4772,22 +4853,21 @@ sealed class RemoteHostError { sealed class RemoteCtrlError { val string: String get() = when (this) { is Inactive -> "inactive" + is BadState -> "badState" is Busy -> "busy" is Timeout -> "timeout" is Disconnected -> "disconnected" - is ConnectionLost -> "connectionLost" - is CertificateExpired -> "certificateExpired" - is CertificateUntrusted -> "certificateUntrusted" - is BadFingerprint -> "badFingerprint" + is BadInvitation -> "badInvitation" + is BadVersion -> "badVersion" } @Serializable @SerialName("inactive") object Inactive: RemoteCtrlError() + @Serializable @SerialName("badState") object BadState: RemoteCtrlError() @Serializable @SerialName("busy") object Busy: RemoteCtrlError() @Serializable @SerialName("timeout") object Timeout: RemoteCtrlError() @Serializable @SerialName("disconnected") class Disconnected(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() - @Serializable @SerialName("connectionLost") class ConnectionLost(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() - @Serializable @SerialName("certificateExpired") class CertificateExpired(val remoteCtrlId: Long): RemoteCtrlError() - @Serializable @SerialName("certificateUntrusted") class CertificateUntrusted(val remoteCtrlId: Long): RemoteCtrlError() - @Serializable @SerialName("badFingerprint") object BadFingerprint: RemoteCtrlError() + @Serializable @SerialName("badInvitation") object BadInvitation: RemoteCtrlError() + @Serializable @SerialName("badVersion") data class BadVersion(val appVersion: String): RemoteCtrlError() + //@Serializable @SerialName("protocolError") data class ProtocolError(val protocolError: RemoteProtocolError): RemoteCtrlError() } enum class NotificationsMode() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index b10a30233f..7d5b1b0196 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -18,6 +18,8 @@ enum class AppPlatform { expect val appPlatform: AppPlatform +expect val deviceName: String + val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE else diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 91fb4a6e8b..8ce39eea35 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -11,10 +11,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @Composable -expect fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) - -@Composable -fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { +fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { Column( Modifier .fillMaxSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 66cac7204a..caf8ec5cb0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -26,6 +26,7 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* +import chat.simplex.common.views.remote.ConnectDesktopView import chat.simplex.common.views.remote.connectMobileDevice import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @@ -42,6 +43,7 @@ fun UserPicker( showSettings: Boolean = true, showCancel: Boolean = false, cancelClicked: () -> Unit = {}, + useFromDesktopClicked: () -> Unit = {}, settingsClicked: () -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -203,6 +205,15 @@ fun UserPicker( Divider(Modifier.requiredHeight(1.dp)) } } + if (appPlatform.isAndroid) { + UseFromDesktopPickerItem { + ModalManager.start.showCustomModal { close -> + ConnectDesktopView(close) + } + userPickerState.value = AnimatedViewState.GONE + } + Divider(Modifier.requiredHeight(1.dp)) + } if (showSettings) { SettingsPickerItem(settingsClicked) } @@ -363,6 +374,16 @@ fun LocalDeviceRow(active: Boolean) { } } +@Composable +private fun UseFromDesktopPickerItem(onClick: () -> Unit) { + SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { + val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current) + Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) + } +} + @Composable private fun SettingsPickerItem(onClick: () -> Unit) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 7128d2185c..4ed5ea56b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -52,6 +52,14 @@ fun annotatedStringResource(id: StringResource): AnnotatedString { } } +@Composable +fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedString { + val density = LocalDensity.current + return remember(id) { + escapedHtmlToAnnotatedString(id.localized().format(args), density) + } +} + // maximum image file size to be auto-accepted const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt new file mode 100644 index 0000000000..d631836dd6 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -0,0 +1,472 @@ +package chat.simplex.common.views.remote + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewLongClickable +import SectionSpacer +import SectionView +import TextIconSpaced +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.switchToLocalSession +import chat.simplex.common.model.ChatModel.connectedToRemote +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ConnectDesktopView(close: () -> Unit) { + val deviceName = remember { controller.appPrefs.deviceNameForRemoteAccess.state } + val closeWithAlert = { + if (!connectedToRemote()) { + close() + } else { + showDisconnectDesktopAlert(close) + } + } + ModalView(close = closeWithAlert) { + ConnectDesktopLayout( + deviceName = deviceName.value!!, + ) + } + val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } + DisposableEffect(Unit) { + withBGApi { + if (!ntfModeService) platform.androidServiceStart() + } + onDispose { + if (!ntfModeService) platform.androidServiceSafeStop() + } + } +} + +@Composable +private fun ConnectDesktopLayout(deviceName: String) { + val sessionAddress = remember { mutableStateOf("") } + val remoteCtrls = remember { mutableStateListOf() } + val session = remember { chatModel.remoteCtrlSession }.value + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + if (session != null) { + when (session.sessionState) { + is UIRemoteCtrlSessionState.Starting -> ConnectingDesktop(session, null) + is UIRemoteCtrlSessionState.Connecting -> ConnectingDesktop(session, session.sessionState.remoteCtrl_) + is UIRemoteCtrlSessionState.PendingConfirmation -> { + if (controller.appPrefs.confirmRemoteSessions.get() || session.sessionState.remoteCtrl_ == null) { + VerifySession(session, session.sessionState.remoteCtrl_, session.sessionCode!!, remoteCtrls) + } else { + ConnectingDesktop(session, session.sessionState.remoteCtrl_) + LaunchedEffect(Unit) { + verifyDesktopSessionCode(remoteCtrls, session.sessionCode!!) + } + } + } + + is UIRemoteCtrlSessionState.Connected -> ActiveSession(session, session.sessionState.remoteCtrl) + } + } else { + ConnectDesktop(deviceName, remoteCtrls, sessionAddress) + } + SectionBottomSpacer() + } + DisposableEffect(Unit) { + setDeviceName(deviceName) + updateRemoteCtrls(remoteCtrls) + onDispose { + if (chatModel.remoteCtrlSession.value != null) { + disconnectDesktop() + } + } + } +} + +@Composable +private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList, sessionAddress: MutableState) { + AppBarTitle(stringResource(MR.strings.connect_to_desktop)) + SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + DevicesView(deviceName, remoteCtrls) { + if (it != "") { + setDeviceName(it) + controller.appPrefs.deviceNameForRemoteAccess.set(it) + } + } + } + SectionDividerSpaced() + ScanDesktopAddressView(sessionAddress) + if (controller.appPrefs.developerTools.get()) { + SectionSpacer() + DesktopAddressView(sessionAddress) + } +} + +@Composable +private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { + AppBarTitle(stringResource(MR.strings.connecting_to_desktop)) + SectionView(stringResource(MR.strings.connecting_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + if (session.sessionCode != null) { + SectionSpacer() + SectionView(stringResource(MR.strings.session_code).uppercase()) { + SessionCodeText(session.sessionCode!!) + } + } + + SectionSpacer() + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessCode: String, remoteCtrls: SnapshotStateList) { + AppBarTitle(stringResource(MR.strings.verify_connection)) + SectionView(stringResource(MR.strings.connected_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + SectionSpacer() + + SectionView(stringResource(MR.strings.verify_code_with_desktop).uppercase()) { + SessionCodeText(sessCode) + } + + SectionSpacer() + + SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) { + Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.confirm_verb)) + } + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun CtrlDeviceNameText(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { + val newDesktop = annotatedStringResource(MR.strings.new_desktop) + val text = remember(rc) { + var t = AnnotatedString(rc?.deviceViewName ?: session.ctrlAppInfo.deviceName) + if (rc == null) { + t = t + AnnotatedString(" ") + newDesktop + } + t + } + Text(text) +} + +@Composable +private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { + val thisDeviceVersion = annotatedStringResource(MR.strings.this_device_version, session.appVersion) + val text = remember(session) { + val v = AnnotatedString(session.ctrlAppInfo.appVersionRange.maxVersion) + var t = AnnotatedString("v$v") + if (v.text != session.appVersion) { + t = t + AnnotatedString(" ") + thisDeviceVersion + } + t + } + Text(text) +} + +@Composable +private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo) { + AppBarTitle(stringResource(MR.strings.connected_to_desktop)) + SectionView(stringResource(MR.strings.connected_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text(rc.deviceViewName) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + if (session.sessionCode != null) { + SectionSpacer() + SectionView(stringResource(MR.strings.session_code).uppercase()) { + SessionCodeText(session.sessionCode!!) + } + } + + SectionSpacer() + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun SessionCodeText(code: String) { + SelectionContainer { + Text( + code.substring(0, 23), + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp) + ) + } +} + +@Composable +private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList, updateDeviceName: (String) -> Unit) { + DeviceNameField(deviceName) { updateDeviceName(it) } + if (remoteCtrls.isNotEmpty()) { + SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) } }) { + Text(generalGetString(MR.strings.linked_desktops)) + } + } +} + +@Composable +private fun ScanDesktopAddressView(sessionAddress: MutableState) { + SectionView(stringResource(MR.strings.scan_qr_code_from_desktop).uppercase()) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + .padding(DEFAULT_PADDING) + ) { + QRCodeScanner { text -> + sessionAddress.value = text + processDesktopQRCode(sessionAddress, text) + } + } + } +} + +@Composable +private fun DesktopAddressView(sessionAddress: MutableState) { + val clipboard = LocalClipboardManager.current + SectionView(stringResource(MR.strings.desktop_address).uppercase()) { + if (sessionAddress.value.isEmpty()) { + SettingsActionItem( + painterResource(MR.images.ic_content_paste), + stringResource(MR.strings.paste_desktop_address), + disabled = !clipboard.hasText(), + click = { + sessionAddress.value = clipboard.getText()?.text ?: "" + }, + ) + } else { + Row(Modifier.padding(horizontal = DEFAULT_PADDING).fillMaxWidth()) { + val state = remember { + mutableStateOf(TextFieldValue(sessionAddress.value)) + } + DefaultBasicTextField( + Modifier.fillMaxWidth(), + state, + color = MaterialTheme.colors.secondary, + ) { + state.value = it + } + KeyChangeEffect(state.value.text) { + if (state.value.text.isNotEmpty()) { + sessionAddress.value = state.value.text + } + } + } + } + SettingsActionItem( + painterResource(MR.images.ic_wifi), + stringResource(MR.strings.connect_to_desktop), + disabled = sessionAddress.value.isEmpty(), + click = { + connectDesktopAddress(sessionAddress, sessionAddress.value) + }, + ) + } +} + +@Composable +private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + AppBarTitle(stringResource(MR.strings.linked_desktops)) + SectionView(stringResource(MR.strings.desktop_devices).uppercase()) { + remoteCtrls.forEach { rc -> + val showMenu = rememberSaveable { mutableStateOf(false) } + SectionItemViewLongClickable(click = {}, longClick = { showMenu.value = true }) { + RemoteCtrl(rc) + DefaultDropdownMenu(showMenu) { + ItemAction(stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), color = Color.Red) { + unlinkDesktop(remoteCtrls, rc) + showMenu.value = false + } + } + } + + } + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.linked_desktop_options).uppercase()) { + PreferenceToggle(stringResource(MR.strings.verify_connections), remember { controller.appPrefs.confirmRemoteSessions.state }.value) { + controller.appPrefs.confirmRemoteSessions.set(it) + } + PreferenceToggle(stringResource(MR.strings.discover_on_network), remember { controller.appPrefs.connectRemoteViaMulticast.state }.value && false) { + controller.appPrefs.confirmRemoteSessions.set(it) + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun RemoteCtrl(rc: RemoteCtrlInfo) { + Text(rc.deviceViewName) +} + +private fun setDeviceName(name: String) { + withBGApi { + controller.setLocalDeviceName(name) + } +} + +private fun updateRemoteCtrls(remoteCtrls: SnapshotStateList) { + withBGApi { + val res = controller.listRemoteCtrls() + if (res != null) { + remoteCtrls.clear() + remoteCtrls.addAll(res) + } + } +} + +private fun processDesktopQRCode(sessionAddress: MutableState, resp: String) { + connectDesktopAddress(sessionAddress, resp) +} + +private fun connectDesktopAddress(sessionAddress: MutableState, addr: String) { + withBGApi { + val res = controller.connectRemoteCtrl(desktopAddress = addr) + if (res.first != null) { + val (rc_, ctrlAppInfo, v) = res.first!! + sessionAddress.value = "" + chatModel.remoteCtrlSession.value = RemoteCtrlSession( + ctrlAppInfo = ctrlAppInfo, + appVersion = v, + sessionState = UIRemoteCtrlSessionState.Connecting(remoteCtrl_ = rc_) + ) + } else { + val e = res.second ?: return@withBGApi + when { + e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadInvitation -> showBadInvitationErrorAlert() + e.chatError is ChatError.ChatErrorChat && e.chatError.errorType is ChatErrorType.CommandError -> showBadInvitationErrorAlert() + e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadVersion -> showBadVersionAlert(v = e.chatError.remoteCtrlError.appVersion) + e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.VERSION -> showBadVersionAlert(v = null) + e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.CTRL_AUTH -> showDesktopDisconnectedErrorAlert() + else -> { + val errMsg = "${e.responseType}: ${e.details}" + Log.e(TAG, "bad response: $errMsg") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), errMsg) + } + } + } + } +} + +private fun verifyDesktopSessionCode(remoteCtrls: SnapshotStateList, sessCode: String) { + withBGApi { + val rc = controller.verifyRemoteCtrlSession(sessCode) + if (rc != null) { + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = UIRemoteCtrlSessionState.Connected(remoteCtrl = rc, sessionCode = sessCode)) + } + updateRemoteCtrls(remoteCtrls) + } +} + +@Composable +private fun DisconnectButton(onClick: () -> Unit) { + SectionItemView(onClick) { + Icon(painterResource(MR.images.ic_close), generalGetString(MR.strings.disconnect_remote_host), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.disconnect_remote_host)) + } +} + +private fun disconnectDesktop(close: (() -> Unit)? = null) { + withBGApi { + controller.stopRemoteCtrl() + switchToLocalSession() + close?.invoke() + } +} + +private fun unlinkDesktop(remoteCtrls: SnapshotStateList, rc: RemoteCtrlInfo) { + withBGApi { + controller.deleteRemoteCtrl(rc.remoteCtrlId) + remoteCtrls.removeAll { it.remoteCtrlId == rc.remoteCtrlId } + } +} + +private fun showUnlinkDesktopAlert(remoteCtrls: SnapshotStateList, rc: RemoteCtrlInfo) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.unlink_desktop_question), + confirmText = generalGetString(MR.strings.unlink_desktop), + destructive = true, + onConfirm = { + unlinkDesktop(remoteCtrls, rc) + } + ) +} + +private fun showDisconnectDesktopAlert(close: (() -> Unit)?) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.disconnect_desktop_question), + text = generalGetString(MR.strings.only_one_device_can_work_at_the_same_time), + confirmText = generalGetString(MR.strings.disconnect_remote_host), + destructive = true, + onConfirm = { disconnectDesktop(close) } + ) +} + +private fun showBadInvitationErrorAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.bad_desktop_address), + ) +} + +private fun showBadVersionAlert(v: String?) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.desktop_incompatible_version), + text = generalGetString(MR.strings.desktop_app_version_is_incompatible).format(v ?: "") + ) +} + +private fun showDesktopDisconnectedErrorAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.desktop_connection_terminated), + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index d00b9bb67a..0d90e59450 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -141,7 +141,7 @@ fun ConnectMobileLayout( } @Composable -private fun DeviceNameField( +fun DeviceNameField( initialValue: String, onChange: (String) -> Unit ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 5fa3c41478..2d4e5c86b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -29,6 +29,7 @@ import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.remote.ConnectDesktopView import chat.simplex.common.views.remote.ConnectMobileView import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -158,6 +159,8 @@ fun SettingsLayout( ChatPreferencesItem(showCustomModal, stopped = stopped) if (appPlatform.isDesktop) { SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView(it) }, disabled = stopped, extraPadding = true) + } else { + SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true) } } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index eb3b9207b7..616e78651d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -954,6 +954,7 @@ CALLS Incognito mode EXPERIMENTAL + Use from desktop Your chat database @@ -1636,6 +1637,7 @@ Verify connection Verify code on mobile This device name + (this device v%s)]]> Connected mobile Connected to mobile Enter this device name… @@ -1644,8 +1646,32 @@ This device Devices New mobile device + Unlink desktop? + Unlink Disconnect + Disconnect desktop? + Only one device can work at the same time Use from desktop in mobile app and scan QR code]]> + Bad desktop address + Incompatible version + Desktop app version %s is not compatible with this app. + Connection terminated + Session code + Connecting to desktop + Connect to desktop + Connected to desktop + Connected desktop + Verify code with desktop + (new)]]> + Linked desktops + Desktop devices + Linked desktop options + Scan QR code from desktop + Desktop address + Verify connections + Discover on network + Paste desktop address + Desktop Coming soon! diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index 7193fbe2be..92111f162a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -2,11 +2,15 @@ package chat.simplex.common.platform import chat.simplex.common.model.* import chat.simplex.common.views.call.RcvCallInvitation +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.helpers.withBGApi import java.util.* +import chat.simplex.res.MR actual val appPlatform = AppPlatform.DESKTOP +actual val deviceName = generalGetString(MR.strings.desktop_device) + @Suppress("ConstantLocale") val defaultLocale: Locale = Locale.getDefault() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt deleted file mode 100644 index 7ea2ef5368..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt +++ /dev/null @@ -1,8 +0,0 @@ -package chat.simplex.common.views.chat - -import androidx.compose.runtime.Composable - -@Composable -actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - ScanCodeLayout(verifyCode, close) -} From 59392b361b9f7b775bbe6dae0c97386530fc87f3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 19 Nov 2023 11:06:49 +0000 Subject: [PATCH 215/294] ui: translations (#3392) * Translated using Weblate (Polish) Currently translated at 97.4% (1400 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 99.0% (1423 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Polish) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Polish) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (French) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (French) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/ * Translated using Weblate (Italian) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (German) Currently translated at 100.0% (1437 of 1437 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (German) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1300 of 1300 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Polish) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (French) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Italian) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (1439 of 1439 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * update de: Meine -> Ihre * nl: Gebruiker -> Lid * nl: gebruiker -> lid 2 * ios, nl: gebruiker -> lid * ios, nl: gebruiker -> lid 2 * android: fix strings * ios: export/import localizations --------- Co-authored-by: B.O.S.S Co-authored-by: Eric Co-authored-by: M1K4 Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: Random Co-authored-by: mlanp Co-authored-by: Eryk Michalak Co-authored-by: Denys Rastiegaiev --- .../bg.xcloc/Localized Contents/bg.xliff | 8 + .../cs.xcloc/Localized Contents/cs.xliff | 8 + .../de.xcloc/Localized Contents/de.xliff | 96 +++++++- .../en.xcloc/Localized Contents/en.xliff | 10 + .../es.xcloc/Localized Contents/es.xliff | 8 + .../fi.xcloc/Localized Contents/fi.xliff | 8 + .../fr.xcloc/Localized Contents/fr.xliff | 76 +++++++ .../it.xcloc/Localized Contents/it.xliff | 78 ++++++- .../ja.xcloc/Localized Contents/ja.xliff | 8 + .../nl.xcloc/Localized Contents/nl.xliff | 104 +++++++-- .../pl.xcloc/Localized Contents/pl.xliff | 76 +++++++ .../ru.xcloc/Localized Contents/ru.xliff | 8 + .../th.xcloc/Localized Contents/th.xliff | 8 + .../uk.xcloc/Localized Contents/uk.xliff | 8 + .../Localized Contents/zh-Hans.xliff | 8 + apps/ios/de.lproj/Localizable.strings | 206 ++++++++++++++++- apps/ios/fr.lproj/Localizable.strings | 186 +++++++++++++++ apps/ios/it.lproj/Localizable.strings | 188 ++++++++++++++- apps/ios/nl.lproj/Localizable.strings | 214 ++++++++++++++++-- apps/ios/pl.lproj/Localizable.strings | 186 +++++++++++++++ .../commonMain/resources/MR/de/strings.xml | 91 ++++++-- .../commonMain/resources/MR/fr/strings.xml | 71 +++++- .../commonMain/resources/MR/it/strings.xml | 73 +++++- .../commonMain/resources/MR/nl/strings.xml | 97 ++++++-- .../commonMain/resources/MR/pl/strings.xml | 69 +++++- .../commonMain/resources/MR/uk/strings.xml | 105 ++++++++- .../resources/MR/zh-rCN/strings.xml | 69 +++++- 27 files changed, 1938 insertions(+), 129 deletions(-) diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 2b8613f929..77133907fb 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! Криптирано съобщение: неочаквана грешка notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Въведете kодa за достъп diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 663dcb022a..fe78e2da82 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! Šifrovaná zpráva: neočekávaná chyba notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Zadat heslo diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 00cef659cf..259d7c12f2 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -89,11 +89,12 @@ %@ and %@ + %@ und %@ No comment provided by engineer. %@ and %@ connected - %@ und %@ wurden verbunden + %@ und %@ wurden mit Ihnen verbunden No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ wurde mit Ihnen verbunden No comment provided by engineer. @@ -132,11 +134,12 @@ %@, %@ and %lld members + %@, %@ und %lld Mitglieder No comment provided by engineer. %@, %@ and %lld other members connected - %@, %@ und %lld weitere Mitglieder wurden verbunden + %@, %@ und %lld weitere Mitglieder wurden mit Ihnen verbunden No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld Gruppenereignisse No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld Nachrichten blockiert No comment provided by engineer. %lld messages marked deleted + %lld Nachrichten als gelöscht markiert No comment provided by engineer. %lld messages moderated by %@ + %lld Nachrichten von %@ moderiert No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sek time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Alle neuen Nachrichten von %@ werden verborgen! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Bereits verbunden! No comment provided by engineer. Already joining the group! + Sie sind bereits Mitglied der Gruppe! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Blockieren No comment provided by engineer. Block member + Mitglied blockieren No comment provided by engineer. Block member? + Mitglied blockieren? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Mit Ihnen selbst verbinden? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Mit Ihnen selbst verbinden? +Das ist Ihre eigene SimpleX-Adresse! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Mit Ihnen selbst verbinden? +Das ist Ihr eigener Einmal-Link! No comment provided by engineer. Connect via contact address + Über die Kontakt-Adresse verbinden No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Mit %@ verbinden No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Richtiger Name für %@? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Gruppe erstellen No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Profil erstellen No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + %lld Nachrichten löschen? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Kontakt löschen und benachrichtigen No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Kontakt löschen? +Das kann nicht rückgängig gemacht werden! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Verschlüsselte Nachricht: Unerwarteter Fehler notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Zugangscode eingeben @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Geben Sie den Gruppennamen ein… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Geben Sie Ihren Namen ein… No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Erweitern chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Vollständig dezentralisiert – nur für Mitglieder sichtbar. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Die Gruppe besteht bereits No comment provided by engineer. Group already exists! + Die Gruppe besteht bereits! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Ungültiger Name! No comment provided by engineer. @@ -2884,7 +2927,7 @@ This cannot be undone! It seems like you are already connected via this link. If it is not the case, there was an error (%@). - Es sieht so aus, dass Sie bereits über diesen Link verbunden sind. Wenn das nicht der Fall, gab es einen Fehler (%@). + Es sieht so aus, als ob Sie bereits über diesen Link verbunden sind. Wenn das nicht der Fall ist, gab es einen Fehler (%@). No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Der Gruppe beitreten? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Mit dem aktuellen Profil beitreten No comment provided by engineer. Join your group? This is your link for group %@! + Ihrer Gruppe beitreten? +Das ist Ihr Link für die Gruppe %@! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + Die Nachrichten von %@ werden angezeigt! No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Gruppe öffnen No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Profilname No comment provided by engineer. Profile name: + Profilname: No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Verbindungsanfrage wiederholen? No comment provided by engineer. Repeat join request? + Verbindungsanfrage wiederholen? No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Zum Verbinden antippen No comment provided by engineer. @@ -4876,10 +4930,12 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This is your own SimpleX address! + Das ist Ihre eigene SimpleX-Adresse! No comment provided by engineer. This is your own one-time link! + Das ist Ihr eigener Einmal-Link! No comment provided by engineer. @@ -4926,7 +4982,7 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. - Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite **Meine Chat-Profile** ein, um Ihr verborgenes Profil zu sehen. + Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite **Ihre Chat-Profile** ein, um Ihr verborgenes Profil zu sehen. No comment provided by engineer. @@ -4981,14 +5037,17 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Unblock + Freigeben No comment provided by engineer. Unblock member + Mitglied freigeben No comment provided by engineer. Unblock member? + Mitglied freigeben? No comment provided by engineer. @@ -5315,7 +5374,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s You - Meine Daten + Ihre Daten No comment provided by engineer. @@ -5340,31 +5399,39 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s You are already connecting to %@. + Sie sind bereits mit %@ verbunden. No comment provided by engineer. You are already connecting via this one-time link! + Sie sind bereits über diesen Einmal-Link verbunden! No comment provided by engineer. You are already in group %@. + Sie sind bereits Mitglied der Gruppe %@. No comment provided by engineer. You are already joining the group %@. + Sie sind bereits Mitglied der Gruppe %@. No comment provided by engineer. You are already joining the group via this link! + Sie sind über diesen Link bereits Mitglied der Gruppe! No comment provided by engineer. You are already joining the group via this link. + Sie sind über diesen Link bereits Mitglied der Gruppe. No comment provided by engineer. You are already joining the group! Repeat join request? + Sie sind bereits Mitglied dieser Gruppe! +Verbindungsanfrage wiederholen? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + Sie haben über diese Adresse bereits eine Verbindung beantragt! No comment provided by engineer. You have already requested connection! Repeat connection request? + Sie haben bereits ein Verbindungsanfrage beantragt! +Verbindungsanfrage wiederholen? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Sie werden verbunden, sobald das Endgerät des Gruppenlink-Hosts online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Sie werden mit allen Gruppenmitgliedern verbunden. No comment provided by engineer. @@ -5586,7 +5658,7 @@ Repeat connection request? Your SimpleX address - Meine SimpleX-Adresse + Ihre SimpleX-Adresse No comment provided by engineer. @@ -5611,7 +5683,7 @@ Repeat connection request? Your chat profiles - Meine Chat-Profile + Ihre Chat-Profile No comment provided by engineer. @@ -5655,16 +5727,17 @@ Sie können es in den Einstellungen ändern. Your preferences - Meine Präferenzen + Ihre Präferenzen No comment provided by engineer. Your privacy - Meine Privatsphäre + Ihre Privatsphäre No comment provided by engineer. Your profile + Mein Profil No comment provided by engineer. @@ -5701,7 +5774,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. Your settings - Meine Einstellungen + Ihre Einstellungen No comment provided by engineer. @@ -5761,6 +5834,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. and %lld other events + und %lld weitere Ereignisse No comment provided by engineer. @@ -5780,6 +5854,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. blocked + blockiert No comment provided by engineer. @@ -5954,6 +6029,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. deleted contact + Gelöschter Kontakt rcv direct event chat item diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 696465adcf..aa128d59d9 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1994,6 +1994,16 @@ This cannot be undone! Encrypted message: unexpected error notification + + Encryption re-negotiation error + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Enter Passcode diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index a27d02aa68..4a76e3ddb4 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! Mensaje cifrado: error inesperado notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Introduce Código diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 10e249cfdf..2661dc10a1 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -1960,6 +1960,14 @@ This cannot be undone! Salattu viesti: odottamaton virhe notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Syötä pääsykoodi diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 381a50fe8f..6a5f397557 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ et %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ connecté(e) No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ et %lld membres No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld événements de groupe No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld messages bloqués No comment provided by engineer. %lld messages marked deleted + %lld messages marqués comme supprimés No comment provided by engineer. %lld messages moderated by %@ + %lld messages modérés par %@ No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sec time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Tous les nouveaux messages de %@ seront cachés ! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Déjà en connexion ! No comment provided by engineer. Already joining the group! + Groupe déjà rejoint ! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Bloquer No comment provided by engineer. Block member + Bloquer ce membre No comment provided by engineer. Block member? + Bloquer ce membre ? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Se connecter à soi-même ? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Se connecter à soi-même ? +C'est votre propre adresse SimpleX ! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Se connecter à soi-même ? +Il s'agit de votre propre lien unique ! No comment provided by engineer. Connect via contact address + Se connecter via l'adresse de contact No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Se connecter avec %@ No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Corriger le nom pour %@ ? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Créer un groupe No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Créer le profil No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + Supprimer %lld messages ? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Supprimer et en informer le contact No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Supprimer le contact ? +Cette opération ne peut être annulée ! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Message chiffrée : erreur inattendue notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Entrer le code d'accès @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Entrer un nom de groupe… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Entrez votre nom… No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Développer chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Entièrement décentralisé – visible que par ses membres. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Le groupe existe déjà No comment provided by engineer. Group already exists! + Ce groupe existe déjà ! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Nom invalide ! No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Rejoindre le groupe ? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Rejoindre avec le profil actuel No comment provided by engineer. Join your group? This is your link for group %@! + Rejoindre votre groupe ? +Voici votre lien pour le groupe %@ ! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + Les messages de %@ seront affichés ! No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Ouvrir le groupe No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Nom du profil No comment provided by engineer. Profile name: + Nom du profil : No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Répéter la demande de connexion ? No comment provided by engineer. Repeat join request? + Répéter la requête d'adhésion ? No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Tapez pour vous connecter No comment provided by engineer. @@ -4876,10 +4930,12 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. This is your own SimpleX address! + Voici votre propre adresse SimpleX ! No comment provided by engineer. This is your own one-time link! + Voici votre propre lien unique ! No comment provided by engineer. @@ -4981,14 +5037,17 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Unblock + Débloquer No comment provided by engineer. Unblock member + Débloquer ce membre No comment provided by engineer. Unblock member? + Débloquer ce membre ? No comment provided by engineer. @@ -5340,31 +5399,39 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien You are already connecting to %@. + Vous êtes déjà en train de vous connecter à %@. No comment provided by engineer. You are already connecting via this one-time link! + Vous êtes déjà connecté(e) via ce lien unique ! No comment provided by engineer. You are already in group %@. + Vous êtes déjà dans le groupe %@. No comment provided by engineer. You are already joining the group %@. + Vous êtes déjà en train de rejoindre le groupe %@. No comment provided by engineer. You are already joining the group via this link! + Vous êtes déjà en train de rejoindre le groupe via ce lien ! No comment provided by engineer. You are already joining the group via this link. + Vous êtes déjà en train de rejoindre le groupe via ce lien. No comment provided by engineer. You are already joining the group! Repeat join request? + Vous êtes déjà membre de ce groupe ! +Répéter la demande d'adhésion ? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + Vous avez déjà demandé une connexion via cette adresse ! No comment provided by engineer. You have already requested connection! Repeat connection request? + Vous avez déjà demandé une connexion ! +Répéter la demande de connexion ? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Vous serez connecté(e) lorsque l'appareil de l'hôte du lien de groupe sera en ligne, veuillez patienter ou vérifier plus tard ! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Vous vous connecterez à tous les membres du groupe. No comment provided by engineer. @@ -5665,6 +5737,7 @@ Vous pouvez modifier ce choix dans les Paramètres. Your profile + Votre profil No comment provided by engineer. @@ -5761,6 +5834,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. and %lld other events + et %lld autres événements No comment provided by engineer. @@ -5780,6 +5854,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. blocked + blocké No comment provided by engineer. @@ -5954,6 +6029,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. deleted contact + contact supprimé rcv direct event chat item diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 1faabca974..72cda836ac 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ e %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ si è connesso/a No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ e %lld membri No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld eventi del gruppo No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld messaggi bloccati No comment provided by engineer. %lld messages marked deleted + %lld messaggi contrassegnati eliminati No comment provided by engineer. %lld messages moderated by %@ + %lld messaggi moderati da %@ No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sec time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Tutti i nuovi messaggi da %@ verrranno nascosti! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Già in connessione! No comment provided by engineer. Already joining the group! + Già in ingresso nel gruppo! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Blocca No comment provided by engineer. Block member + Blocca membro No comment provided by engineer. Block member? + Bloccare il membro? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Connettersi a te stesso? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Connettersi a te stesso? +Questo è il tuo indirizzo SimpleX! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Connettersi a te stesso? +Questo è il tuo link una tantum! No comment provided by engineer. Connect via contact address + Connettere via indirizzo del contatto No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Connettersi con %@ No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Correggere il nome a %@? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Crea gruppo No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Crea profilo No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + Eliminare %lld messaggi? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Elimina e avvisa il contatto No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Eliminare il contatto? +Non è reversibile! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Messaggio crittografato: errore imprevisto notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Inserisci il codice di accesso @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Inserisci il nome del gruppo… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Inserisci il tuo nome… No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Espandi chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Completamente decentralizzato: visibile solo ai membri. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Il gruppo esiste già No comment provided by engineer. Group already exists! + Il gruppo esiste già! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Nome non valido! No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Entrare nel gruppo? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Entra con il profilo attuale No comment provided by engineer. Join your group? This is your link for group %@! + Entrare nel tuo gruppo? +Questo è il tuo link per il gruppo %@! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + I messaggi da %@ verranno mostrati! No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Apri gruppo No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Nome del profilo No comment provided by engineer. Profile name: + Nome del profilo: No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Ripetere la richiesta di connessione? No comment provided by engineer. Repeat join request? + Ripetere la richiesta di ingresso? No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Tocca per connettere No comment provided by engineer. @@ -4876,10 +4930,12 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. This is your own SimpleX address! + Questo è il tuo indirizzo SimpleX! No comment provided by engineer. This is your own one-time link! + Questo è il tuo link una tantum! No comment provided by engineer. @@ -4981,14 +5037,17 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Unblock + Sblocca No comment provided by engineer. Unblock member + Sblocca membro No comment provided by engineer. Unblock member? + Sbloccare il membro? No comment provided by engineer. @@ -5340,31 +5399,39 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e You are already connecting to %@. + Ti stai già connettendo a %@. No comment provided by engineer. You are already connecting via this one-time link! + Ti stai già connettendo tramite questo link una tantum! No comment provided by engineer. You are already in group %@. + Sei già nel gruppo %@. No comment provided by engineer. You are already joining the group %@. + Stai già entrando nel gruppo %@. No comment provided by engineer. You are already joining the group via this link! + Stai già entrando nel gruppo tramite questo link! No comment provided by engineer. You are already joining the group via this link. + Stai già entrando nel gruppo tramite questo link. No comment provided by engineer. You are already joining the group! Repeat join request? + Stai già entrando nel gruppo! +Ripetere la richiesta di ingresso? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + Hai già richiesto la connessione tramite questo indirizzo! No comment provided by engineer. You have already requested connection! Repeat connection request? + Hai già richiesto la connessione! +Ripetere la richiesta di connessione? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Verrai connesso/a quando il dispositivo dell'host del gruppo sarà in linea, attendi o controlla più tardi! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Ti connetterai a tutti i membri del gruppo. No comment provided by engineer. @@ -5665,6 +5737,7 @@ Puoi modificarlo nelle impostazioni. Your profile + Il tuo profilo No comment provided by engineer. @@ -5761,6 +5834,7 @@ I server di SimpleX non possono vedere il tuo profilo. and %lld other events + e altri %lld eventi No comment provided by engineer. @@ -5780,6 +5854,7 @@ I server di SimpleX non possono vedere il tuo profilo. blocked + bloccato No comment provided by engineer. @@ -5814,7 +5889,7 @@ I server di SimpleX non possono vedere il tuo profilo. changed role of %1$@ to %2$@ - cambiato il ruolo di %1$@ in %2$@ + ha cambiato il ruolo di %1$@ in %2$@ rcv group event chat item @@ -5954,6 +6029,7 @@ I server di SimpleX non possono vedere il tuo profilo. deleted contact + contatto eliminato rcv direct event chat item diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 9f688d1ff5..b574faecb1 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1963,6 +1963,14 @@ This cannot be undone! 暗号化されたメッセージ : 予期しないエラー notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode パスコードを入力 diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 257b2ff0ce..92cb85456f 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ en %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ verbonden No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ en %lld leden No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld groep gebeurtenissen No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld berichten geblokkeerd No comment provided by engineer. %lld messages marked deleted + %lld berichten gemarkeerd als verwijderd No comment provided by engineer. %lld messages moderated by %@ + %lld berichten gemodereerd door %@ No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sec time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Alle nieuwe berichten van %@ worden verborgen! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Al bezig met verbinden! No comment provided by engineer. Already joining the group! + Al lid van de groep! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Blokkeren No comment provided by engineer. Block member + Lid blokkeren No comment provided by engineer. Block member? + Lid blokkeren? No comment provided by engineer. @@ -977,7 +991,7 @@ Change member role? - Rol van gebruiker wijzigen? + Rol van lid wijzigen? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Verbinding maken met jezelf? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Verbinding maken met jezelf? +Dit is uw eigen SimpleX adres! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Verbinding maken met jezelf? +Dit is uw eigen eenmalige link! No comment provided by engineer. Connect via contact address + Verbinding maken via contactadres No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Verbonden met %@ No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Juiste naam voor %@? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Groep aanmaken No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Maak een profiel aan No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + %lld berichten verwijderen? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Contact verwijderen en op de hoogte stellen No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Verwijder contact? +Dit kan niet ongedaan gemaakt worden! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Versleuteld bericht: onverwachte fout notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Voer toegangscode in @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Groep naam invoeren… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Vul uw naam in… No comment provided by engineer. @@ -2031,7 +2069,7 @@ This cannot be undone! Error adding member(s) - Fout bij het toevoegen van gebruiker(s) + Fout bij het toevoegen van leden No comment provided by engineer. @@ -2161,7 +2199,7 @@ This cannot be undone! Error removing member - Fout bij verwijderen van gebruiker + Fout bij verwijderen van lid No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Uitbreiden chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Volledig gedecentraliseerd – alleen zichtbaar voor leden. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Groep bestaat al No comment provided by engineer. Group already exists! + Groep bestaat al! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Ongeldige naam! No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Deelnemen aan groep? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Word lid met huidig profiel No comment provided by engineer. Join your group? This is your link for group %@! + Sluit u aan bij uw groep? +Dit is jouw link voor groep %@! No comment provided by engineer. @@ -3077,22 +3124,22 @@ This is your link for group %@! Member - Gebruiker + Lid No comment provided by engineer. Member role will be changed to "%@". All group members will be notified. - De rol van gebruiker wordt gewijzigd in "%@". Alle groepsleden worden op de hoogte gebracht. + De rol van lid wordt gewijzigd in "%@". Alle groepsleden worden op de hoogte gebracht. No comment provided by engineer. Member role will be changed to "%@". The member will receive a new invitation. - De rol van gebruiker wordt gewijzigd in "%@". Het lid ontvangt een nieuwe uitnodiging. + De rol van lid wordt gewijzigd in "%@". Het lid ontvangt een nieuwe uitnodiging. No comment provided by engineer. Member will be removed from group - this cannot be undone! - Gebruiker wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! + Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + Berichten van %@ worden getoond! No comment provided by engineer. @@ -3415,7 +3463,7 @@ This is your link for group %@! Only group owners can enable files and media. - Alleen groepseigenaren kunnen bestanden en media inschakelen. + Alleen groep eigenaren kunnen bestanden en media inschakelen. No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Open groep No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Profielnaam No comment provided by engineer. Profile name: + Profielnaam: No comment provided by engineer. @@ -3927,12 +3978,12 @@ This is your link for group %@! Remove member - Gebruiker verwijderen + Lid verwijderen No comment provided by engineer. Remove member? - Gebruiker verwijderen? + Lid verwijderen? No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Verbindingsverzoek herhalen? No comment provided by engineer. Repeat join request? + Deelnameverzoek herhalen? No comment provided by engineer. @@ -4075,7 +4128,7 @@ This is your link for group %@! Save and notify group members - Opslaan en Groep leden melden + Opslaan en groep leden melden No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Tik om verbinding te maken No comment provided by engineer. @@ -4689,12 +4743,12 @@ This is your link for group %@! Tap to join - Tik om mee te doen + Tik om lid te worden No comment provided by engineer. Tap to join incognito - Tik om incognito deel te nemen + Tik om incognito lid te worden No comment provided by engineer. @@ -4876,10 +4930,12 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. This is your own SimpleX address! + Dit is uw eigen SimpleX adres! No comment provided by engineer. This is your own one-time link! + Dit is uw eigen eenmalige link! No comment provided by engineer. @@ -4981,14 +5037,17 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Unblock + Deblokkeren No comment provided by engineer. Unblock member + Lid deblokkeren No comment provided by engineer. Unblock member? + Lid deblokkeren? No comment provided by engineer. @@ -5340,31 +5399,39 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak You are already connecting to %@. + U maakt al verbinding met %@. No comment provided by engineer. You are already connecting via this one-time link! + Je maakt al verbinding via deze eenmalige link! No comment provided by engineer. You are already in group %@. + Je zit al in groep %@. No comment provided by engineer. You are already joining the group %@. + Je bent al lid van de groep %@. No comment provided by engineer. You are already joining the group via this link! + Je wordt al lid van de groep via deze link! No comment provided by engineer. You are already joining the group via this link. + Je wordt al lid van de groep via deze link. No comment provided by engineer. You are already joining the group! Repeat join request? + Je sluit je al aan bij de groep! +Deelnameverzoek herhalen? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + U heeft al een verbinding aangevraagd via dit adres! No comment provided by engineer. You have already requested connection! Repeat connection request? + Je hebt al verbinding aangevraagd! +Verbindingsverzoek herhalen? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + U wordt verbonden wanneer het apparaat van de groep link host online is. Wacht even of controleer het later opnieuw! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Je maakt verbinding met alle leden. No comment provided by engineer. @@ -5665,6 +5737,7 @@ U kunt dit wijzigen in Instellingen. Your profile + Jouw profiel No comment provided by engineer. @@ -5761,6 +5834,7 @@ SimpleX servers kunnen uw profiel niet zien. and %lld other events + en %lld andere gebeurtenissen No comment provided by engineer. @@ -5780,6 +5854,7 @@ SimpleX servers kunnen uw profiel niet zien. blocked + geblokkeerd No comment provided by engineer. @@ -5954,6 +6029,7 @@ SimpleX servers kunnen uw profiel niet zien. deleted contact + verwijderd contact rcv direct event chat item @@ -6168,7 +6244,7 @@ SimpleX servers kunnen uw profiel niet zien. member - gebruiker + lid member role diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 03ac0250a0..5ec6d3ee36 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ i %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ połączony No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ i %lld członków No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld wydarzeń grupy No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld wiadomości zablokowanych No comment provided by engineer. %lld messages marked deleted + %lld wiadomości oznaczonych do usunięcia No comment provided by engineer. %lld messages moderated by %@ + %lld wiadomości zmoderowanych przez %@ No comment provided by engineer. @@ -394,6 +401,7 @@ 0 sec + 0 sek time to disappear @@ -623,6 +631,7 @@ All new messages from %@ will be hidden! + Wszystkie nowe wiadomości z %@ zostaną ukryte! No comment provided by engineer. @@ -732,10 +741,12 @@ Already connecting! + Już połączony! No comment provided by engineer. Already joining the group! + Już dołączono do grupy! No comment provided by engineer. @@ -875,14 +886,17 @@ Block + Zablokuj No comment provided by engineer. Block member + Zablokuj członka No comment provided by engineer. Block member? + Zablokować członka? No comment provided by engineer. @@ -1153,20 +1167,26 @@ Connect to yourself? + Połączyć się ze sobą? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Połączyć się ze sobą? +To jest twój własny adres SimpleX! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Połączyć się ze sobą? +To jest twój jednorazowy link! No comment provided by engineer. Connect via contact address + Połącz przez adres kontaktowy No comment provided by engineer. @@ -1186,6 +1206,7 @@ This is your own one-time link! Connect with %@ + Połącz z %@ No comment provided by engineer. @@ -1285,6 +1306,7 @@ This is your own one-time link! Correct name to %@? + Poprawić imię na %@? No comment provided by engineer. @@ -1309,6 +1331,7 @@ This is your own one-time link! Create group + Utwórz grupę No comment provided by engineer. @@ -1333,6 +1356,7 @@ This is your own one-time link! Create profile + Utwórz profil No comment provided by engineer. @@ -1495,6 +1519,7 @@ This is your own one-time link! Delete %lld messages? + Usunąć %lld wiadomości? No comment provided by engineer. @@ -1524,6 +1549,7 @@ This is your own one-time link! Delete and notify contact + Usuń i powiadom kontakt No comment provided by engineer. @@ -1559,6 +1585,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Usunąć kontakt? +To nie może być cofnięte! No comment provided by engineer. @@ -1966,6 +1994,14 @@ This cannot be undone! Zaszyfrowana wiadomość: nieoczekiwany błąd notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Wprowadź Pin @@ -1978,6 +2014,7 @@ This cannot be undone! Enter group name… + Wpisz nazwę grupy… No comment provided by engineer. @@ -2007,6 +2044,7 @@ This cannot be undone! Enter your name… + Wpisz swoją nazwę… No comment provided by engineer. @@ -2286,6 +2324,7 @@ This cannot be undone! Expand + Rozszerz chat item action @@ -2435,6 +2474,7 @@ This cannot be undone! Fully decentralized – visible only to members. + W pełni zdecentralizowana – widoczna tylko dla członków. No comment provided by engineer. @@ -2459,10 +2499,12 @@ This cannot be undone! Group already exists + Grupa już istnieje No comment provided by engineer. Group already exists! + Grupa już istnieje! No comment provided by engineer. @@ -2814,6 +2856,7 @@ This cannot be undone! Invalid name! + Nieprawidłowa nazwa! No comment provided by engineer. @@ -2909,6 +2952,7 @@ This cannot be undone! Join group? + Dołączyć do grupy? No comment provided by engineer. @@ -2918,11 +2962,14 @@ This cannot be undone! Join with current profile + Dołącz z obecnym profilem No comment provided by engineer. Join your group? This is your link for group %@! + Dołączyć do twojej grupy? +To jest twój link do grupy %@! No comment provided by engineer. @@ -3142,6 +3189,7 @@ This is your link for group %@! Messages from %@ will be shown! + Wiadomości od %@ zostaną pokazane! No comment provided by engineer. @@ -3495,6 +3543,7 @@ This is your link for group %@! Open group + Grupa otwarta No comment provided by engineer. @@ -3704,10 +3753,12 @@ This is your link for group %@! Profile name + Nazwa profilu No comment provided by engineer. Profile name: + Nazwa profilu: No comment provided by engineer. @@ -3957,10 +4008,12 @@ This is your link for group %@! Repeat connection request? + Powtórzyć prośbę połączenia? No comment provided by engineer. Repeat join request? + Powtórzyć prośbę dołączenia? No comment provided by engineer. @@ -4680,6 +4733,7 @@ This is your link for group %@! Tap to Connect + Dotknij aby połączyć No comment provided by engineer. @@ -4876,10 +4930,12 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This is your own SimpleX address! + To jest twój własny adres SimpleX! No comment provided by engineer. This is your own one-time link! + To jest twój jednorazowy link! No comment provided by engineer. @@ -4981,14 +5037,17 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. Unblock + Odblokuj No comment provided by engineer. Unblock member + Odblokuj członka No comment provided by engineer. Unblock member? + Odblokować członka? No comment provided by engineer. @@ -5340,31 +5399,39 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc You are already connecting to %@. + Już się łączysz z %@. No comment provided by engineer. You are already connecting via this one-time link! + Już jesteś połączony z tym jednorazowym linkiem! No comment provided by engineer. You are already in group %@. + Już jesteś w grupie %@. No comment provided by engineer. You are already joining the group %@. + Już dołączasz do grupy %@. No comment provided by engineer. You are already joining the group via this link! + Już dołączasz do grupy przez ten link! No comment provided by engineer. You are already joining the group via this link. + Już dołączasz do grupy przez ten link. No comment provided by engineer. You are already joining the group! Repeat join request? + Już dołączasz do grupy! +Powtórzyć prośbę dołączenia? No comment provided by engineer. @@ -5464,11 +5531,14 @@ Repeat join request? You have already requested connection via this address! + Już prosiłeś o połączenie na ten adres! No comment provided by engineer. You have already requested connection! Repeat connection request? + Już prosiłeś o połączenie! +Powtórzyć prośbę połączenia? No comment provided by engineer. @@ -5523,6 +5593,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Zostaniesz połączony, gdy urządzenie hosta grupy będzie online, proszę czekać lub sprawdzić później! No comment provided by engineer. @@ -5542,6 +5613,7 @@ Repeat connection request? You will connect to all group members. + Zostaniesz połączony ze wszystkimi członkami grupy. No comment provided by engineer. @@ -5665,6 +5737,7 @@ Możesz to zmienić w Ustawieniach. Your profile + Twój profil No comment provided by engineer. @@ -5761,6 +5834,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. and %lld other events + i %lld innych wydarzeń No comment provided by engineer. @@ -5780,6 +5854,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. blocked + zablokowany No comment provided by engineer. @@ -5954,6 +6029,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. deleted contact + usunięto kontakt rcv direct event chat item diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 672a9071c3..402fdc9658 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! Зашифрованное сообщение: неожиданная ошибка notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Введите Код diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 44342f0885..e1451378a5 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1946,6 +1946,14 @@ This cannot be undone! ข้อความที่ encrypt: ข้อผิดพลาดที่ไม่คาดคิด notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode ใส่รหัสผ่าน diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index b4ce70f7df..b3b5e9d39a 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1956,6 +1956,14 @@ This cannot be undone! Зашифроване повідомлення: несподівана помилка notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode Введіть пароль diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index fba15d68cf..929b54a631 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -1966,6 +1966,14 @@ This cannot be undone! 加密消息:意外错误 notification + + Encryption re-negotiation error + message decrypt error item + + + Encryption re-negotiation failed. + No comment provided by engineer. + Enter Passcode 输入密码 diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 2fe8d87b69..00489be829 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -119,11 +119,17 @@ "%@ %@" = "%@ %@"; /* No comment provided by engineer. */ -"%@ and %@ connected" = "%@ und %@ wurden verbunden"; +"%@ and %@" = "%@ und %@"; + +/* No comment provided by engineer. */ +"%@ and %@ connected" = "%@ und %@ wurden mit Ihnen verbunden"; /* copied message info, at Datenschutz & Sicherheit - Meine Privatsphäre + Ihre Privatsphäre App-Bildschirm schützen Bilder automatisch akzeptieren Link-Vorschau senden @@ -712,7 +712,7 @@ Die Gruppeneinladung ist abgelaufen hat %1$s eingeladen. - beigetreten + verbunden hat die Gruppe verlassen änderte die Rolle von %s auf %s änderte Ihre Rolle auf %s @@ -813,8 +813,8 @@ Empfängeradresse wechseln Geheime Gruppe erstellen - Die Gruppe ist vollständig dezentralisiert – sie ist nur für Mitglieder sichtbar. - Anzeigename der Gruppe: + Vollständig dezentralisiert – nur für Mitglieder sichtbar. + Geben Sie den Gruppennamen ein: Vollständiger Gruppenname: Ihr Chat-Profil wird an die Gruppenmitglieder gesendet @@ -1001,7 +1001,7 @@ Transport-Isolation Chat-Profil löschen\? Fehler beim Löschen des Benutzerprofils - Meine Chat-Profile + Ihre Chat-Profile Verbindung Chat-Profil Dateien für alle Chat-Profile löschen @@ -1084,7 +1084,7 @@ Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind. Begrüßungsmeldung Sie können ein Benutzerprofil verbergen oder stummschalten – für das Menü gedrückt halten. - Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite \"Meine Chat-Profile\" ein, um Ihr verborgenes Profil zu sehen. + Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite \"Ihre Chat-Profile\" ein, um Ihr verborgenes Profil zu sehen. Migrations-Bestätigung ungültig Aktualisieren und den Chat öffnen Datenbank-Aktualisierungen bestätigen @@ -1451,9 +1451,9 @@ SimpleX kann nicht im Hintergrund ablaufen. Sie erhalten Benachrichtigungen nur dann, solange die App läuft. Die App kann nach einer Minute im Hintergrund geschlossen werden. App-Akkuverbrauch / Unbeschränkt , um Benachrichtigungen zu aktivieren.]]> - %s, %s und %d weitere Mitglieder wurden verbunden - %s und %s wurden verbunden - %s, %s und %s wurden verbunden + %s, %s und %d weitere Mitglieder wurden mit Ihnen verbunden + %s und %s wurden mit Ihnen verbunden + %s, %s und %s wurden mit Ihnen verbunden Nachrichtenentwurf Letzte Nachrichten anzeigen Die Datenbank wird verschlüsselt und das Passwort in den Einstellungen gespeichert. @@ -1487,4 +1487,59 @@ \n- Schneller und stabiler. Direktnachricht senden Direkt miteinander verbunden + Erweitern + Verbindungsanfrage wiederholen? + Gelöschter Kontakt + Sie sind bereits mit %1$s verbunden. + Fehler + Sie sind über diesen Link bereits Mitglied der Gruppe. + Gruppe erstellen + Profil erstellen + %s und %s + Ihrer Gruppe beitreten? + Sie sind bereits Mitglied in der Gruppe %1$s. + Das ist Ihr eigener Einmal-Link! + %d Nachrichten als gelöscht markiert + Gruppe besteht bereits! + Bereits verbunden! + Das Video kann nicht dekodiert werden. Bitte probieren Sie ein anderes Video aus, oder kontaktieren Sie die Entwickler. + %s wurde mit Ihnen verbunden + und %d weitere Ereignisse + Über einen Link verbinden? + Sie sind bereits Mitglied der Gruppe! + %s, %s und %d Mitglieder + %d Nachrichten von %s moderiert + Mitglied freigeben + Mit Ihnen selbst verbinden? + Zum Verbinden antippen + Sie sind bereits Mitglied in der Gruppe %1$s. + Das ist Ihre eigene SimpleX-Adresse! + Richtiger Name für %s? + %d Nachrichten löschen? + Mit %1$s verbinden? + Mitglied entfernen + Blockieren + Mitglied freigeben? + %d Nachrichten blockiert + Mitglied blockieren + Verbindungsanfrage wiederholen? + Mitglied entfernen? + Kontakt löschen und benachrichtigen + Sie sind bereits über diesen Einmal-Link verbunden! + Gruppe öffnen + Die Nachrichten von %s werden angezeigt! + Fehler beim Senden der Einladung + Sie haben einen ungültigen Datei-Pfad geteilt. Bitte melden Sie diesen Fehler den App-Entwicklern. + Mitglied blockieren? + %d Gruppenereignisse + Ungültiger Name! + Das ist Ihr Link für die Gruppe %1$s! + Freigeben + Ungültiger Datei-Pfad + Sie haben über diese Adresse bereits eine Verbindung beantragt! + Die Konsole in einem neuen Fenster anzeigen + Alle neuen Nachrichten von %s werden ausgeblendet! + blockiert + Fehler bei der Neuverhandlung der Verschlüsselung + Neuverhandlung der Verschlüsselung fehlgeschlagen \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 590ba49db8..cdf07dff6d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1,13 +1,13 @@ k - Se connecter via le lien du contact \? + Se connecter via l\'adresse du contact ? SimpleX Votre profil va être envoyé au contact qui vous a envoyé ce lien. - Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe. + Vous vous connecterez à tous les membres du groupe. Se connecter - Se connecter via le lien du groupe \? - Se connecter via un lien d\'invitation \? + Rejoindre le groupe ? + Se connecter via un lien unique ? erreur connexion connecté @@ -427,7 +427,7 @@ Tous vos contacts resteront connectés. Partager le lien Supprimer l\'adresse - Nom affiché : + Nom du profil : Nom complet : Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts. Les serveurs SimpleX ne peuvent pas voir votre profil. Supprimer l\'image @@ -436,7 +436,7 @@ La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité. Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs. Créer le profil - Nom affiché + Saisissez votre nom : Comment utiliser markdown a + b coloré @@ -835,7 +835,7 @@ reçu, non autorisé Autorise votre contact à envoyer des messages éphémères. directe - Le groupe est entièrement décentralisé – il n\'est visible que par ses membres. + Entièrement décentralisé – visible que par ses membres. Les membres du groupes peuvent envoyer des messages éphémères. Revenir en arrière Interdire l’envoi de messages éphémères. @@ -849,7 +849,7 @@ Seulement votre contact peut envoyer des messages éphémères. Vous et votre contact êtes tous deux en mesure d\'envoyer des messages éphémères. Les messages vocaux sont interdits dans ce groupe. - Nom affiché du groupe : + Saisir le nom du groupe : indirecte (%1$s) Groupe Connexion @@ -1406,4 +1406,59 @@ Envoyer un message direct pour vous connecter envoyer un message direct s\'est connecté.e de manière directe + Développer + Répéter la demande de connexion ? + contact supprimé + Vous êtes déjà connecté(e) à %1$s. + Erreur + Vous êtes déjà en train de rejoindre le groupe via ce lien. + Créer un groupe + Créer le profil + %s et %s + Rejoindre votre groupe ? + Vous êtes déjà en train de rejoindre le groupe %1$s. + Voici votre propre lien unique ! + %d messages marqués comme supprimés + Ce groupe existe déjà ! + Déjà en connexion ! + La vidéo ne peut pas être décodée. Veuillez essayer une autre vidéo ou contacter les développeurs. + %s connecté(e) + et %d autres événements + Se connecter via un lien ? + Groupe déjà rejoint ! + %s, %s et %d membres + %d messages modérés par %s + Débloquer ce membre + Se connecter à soi-même ? + Tapez pour vous connecter + Vous êtes déjà dans le groupe %1$s. + Voici votre propre adresse SimpleX ! + Corriger le nom pour %s ? + Supprimer %d messages ? + Se connecter avec %1$s ? + Retirer le membre + Bloquer + Débloquer ce membre ? + %d messages bloqués + Bloquer ce membre + Répéter la requête d\'adhésion ? + Retirer ce membre ? + Supprimer et en informer le contact + Vous êtes déjà connecté(e) via ce lien unique ! + Ouvrir le groupe + Les messages de %s seront affichés ! + Erreur lors de l\'envoi de l\'invitation + Vous avez partagé un chemin de fichier non valide. Signalez le problème aux développeurs de l\'application. + Bloquer ce membre ? + %d événements de groupe + Nom invalide ! + Voici votre lien pour le groupe %1$s ! + Débloquer + Chemin du fichier invalide + Vous avez déjà demandé une connexion via cette adresse ! + Afficher la console dans une nouvelle fenêtre + Tous les nouveaux messages provenant de %s seront cachés ! + blocké + Erreur lors de la renégociation du chiffrement + La renégociation du chiffrement a échoué. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 10af1170b1..3cdd4676b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -11,8 +11,8 @@ In attesa dell\'immagine SimpleX k - Connettere via link di invito\? - Connettere via link del gruppo\? + Connettere via link una tantum? + Entrare nel gruppo? Il tuo profilo verrà inviato al contatto da cui hai ricevuto questo link. Connetti connesso @@ -200,9 +200,9 @@ Conferma Ripristina OK - Connettere via link del contatto\? + Connettere via indirizzo del contatto? Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %1$s). - Entrerai in un gruppo a cui si riferisce questo link e ti connetterai ai suoi membri. + Ti connetterai a tutti i membri del gruppo. connessione %1$d Descrizione via %1$s @@ -291,7 +291,7 @@ La password del database è necessaria per aprire la chat. Elimina I messaggi diretti tra i membri sono vietati in questo gruppo. - Nome da mostrare + Inserisci il tuo nome: Aggiungi un contatto: per creare il tuo codice QR una tantum per il tuo contatto.]]> Scansiona codice QR: per connetterti al contatto che ti mostra il codice QR.]]> File @@ -346,7 +346,7 @@ Crea Crea profilo Elimina immagine - Nome da mostrare: + Nome del profilo: Il nome da mostrare non può contenere spazi. Modifica immagine Esci senza salvare @@ -415,7 +415,7 @@ File: %s Gruppo inattivo indirizzo cambiato per te - cambiato il ruolo di %s in %s + ha cambiato il ruolo di %s in %s cambiato il tuo ruolo in %s cambio indirizzo… cambio indirizzo per %s… @@ -458,7 +458,7 @@ Errore nella rimozione del membro Errore nel salvataggio del profilo del gruppo Gruppo - Nome da mostrare del gruppo: + Inserisci il nome del gruppo: Il profilo del gruppo è memorizzato sui dispositivi dei membri, non sui server. sempre Sia tu che il tuo contatto potete eliminare irreversibilmente i messaggi inviati. @@ -828,7 +828,7 @@ Cambia indirizzo di ricezione Sistema Scadenza connessione TCP - Il gruppo è completamente decentralizzato: è visibile solo ai membri. + Completamente decentralizzato: visibile solo ai membri. Il ruolo verrà cambiato in \"%s\". Tutti i membri del gruppo riceveranno una notifica. Il ruolo verrà cambiato in \"%s\". Il membro riceverà un nuovo invito. Aggiorna @@ -1406,4 +1406,59 @@ Invia messaggio diretto per connetterti invia messaggio diretto si è connesso/a direttamente + Espandi + Ripetere la richiesta di connessione? + contatto eliminato + Ti stai già connettendo a %1$s. + Errore + Stai già entrando nel gruppo tramite questo link. + Crea gruppo + Crea profilo + %s e %s + Entrare nel tuo gruppo? + Stai già entrando nel gruppo %1$s. + Questo è il tuo link una tantum! + %d messaggi contrassegnati eliminati + Il gruppo esiste già! + Già in connessione! + Il video non può essere decodificato. Prova un video diverso o contatta gli sviluppatori. + %s si è connesso/a + e altri %d eventi + Connettere via link? + Stai già entrando nel gruppo! + %s, %s e %d membri + %d messaggi moderati da %s + Sblocca membro + Connettersi a te stesso? + Tocca per connettere + Sei già nel gruppo %1$s. + Questo è il tuo indirizzo SimpleX! + Correggere il nome a %s? + Eliminare %d messaggi? + Connettersi con %1$s? + Rimuovi membro + Blocca + Sbloccare il membro? + %d messaggi bloccati + Blocca membro + Ripetere la richiesta di ingresso? + Rimuovere il membro? + Elimina e avvisa il contatto + Ti stai già connettendo tramite questo link una tantum! + Apri gruppo + I messaggi da %s verranno mostrati! + Errore di invio dell\'invito + Hai condiviso un percorso di file non valido. Segnala il problema agli sviluppatori dell\'app. + Bloccare il membro? + %d eventi del gruppo + Nome non valido! + Questo è il tuo link per il gruppo %1$s! + Sblocca + Percorso file non valido + Hai già richiesto la connessione tramite questo indirizzo! + Mostra console in una nuova finestra + Tutti i nuovi messaggi di %s verranno nascosti! + bloccato + Errore di rinegoziazione crittografia + Rinegoziazione crittografia fallita. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 7cbb9bf757..522b22f7ab 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -155,9 +155,9 @@ Verbind verbonden Verbinden - Verbinden via contact link\? - Verbinden via groep link\? - Verbinden via uitnodiging link\? + Verbinden via contact link? + Deelnemen aan groep? + Verbinden via eenmalige link? Contact naam Contact verborgen: Decodeerfout @@ -261,7 +261,7 @@ Scan QR-code.]]> Ontwikkel gereedschap Apparaatverificatie is uitgeschakeld. SimpleX Vergrendelen uitschakelen. - Weergavenaam + Vul uw naam in: Apparaatverificatie is niet ingeschakeld. Je kunt SimpleX Vergrendelen inschakelen via Instellingen zodra je apparaatverificatie hebt ingeschakeld. Directe berichten tussen leden zijn verboden in deze groep. %d bestand(en) met een totale grootte van %s @@ -273,7 +273,7 @@ Verdwijnende berichten Verbinding verbreken Verbinding verbroken - Weergavenaam: + Profielnaam: Weergavenaam mag geen spatie bevatten. %d min %d maanden @@ -300,7 +300,7 @@ Fout bij het importeren van de chat database Database versleutelen\? Groep niet gevonden! - Weergave naam groep: + Groep naam invoeren: Dubbele weergavenaam! Fout bij verzenden van bericht Fout bij wisselen van profiel! @@ -340,7 +340,7 @@ Fout bij verwijderen groep link Groep link Fout bij wisselen van rol - Fout bij verwijderen van gebruiker + Fout bij verwijderen van lid Groep Volledige naam groep: Groep voorkeuren @@ -362,7 +362,7 @@ Groep profiel bewerken Schakel TCP keep-alive in Versleutelen - Fout bij het toevoegen van gebruiker(s) + Fout bij het toevoegen van leden Voer de server handmatig in Fout bij het accepteren van een contactverzoek Groep uitnodiging verlopen @@ -482,12 +482,12 @@ \n3. De verbinding is verbroken. Deel nemen aan groep Verlaten - Gebruiker + Lid link voorbeeld afbeelding - GEBRUIKER + LID BERICHTEN EN BESTANDEN Openen in mobiele app en tik vervolgens op Verbinden in de app.]]> - Gebruiker wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! + Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! Fout bij bezorging van bericht Bericht wordt gemarkeerd voor verwijdering. De ontvanger(s) kunnen dit bericht onthullen. Netwerk status @@ -717,7 +717,7 @@ Opslaan en Contact melden Je huidige profiel Opslaan en Contacten melden - Opslaan en Groepsleden melden + Opslaan en groepsleden melden staking Het berichten- en applicatieplatform dat uw privacy en veiligheid beschermt. Het profiel wordt alleen gedeeld met uw contacten. @@ -775,7 +775,7 @@ De rol wordt gewijzigd in \"%s\". Iedereen in de groep wordt op de hoogte gebracht. Ontvang via Groep profiel opslaan - De groep is volledig gedecentraliseerd – het is alleen zichtbaar voor de leden. + Volledig gedecentraliseerd – alleen zichtbaar voor leden. Timeout van TCP-verbinding Spraak berichten Spraak berichten zijn verboden in dit gesprek. @@ -791,10 +791,10 @@ Databasefout herstellen Onbekende fout Verkeerd wachtwoord! - U bent uitgenodigd voor de groep. Word lid om in contact te komen met groepsleden. - Tik om mee te doen - Tik om incognito deel te nemen - Je bent lid geworden van deze groep. Verbinding maken met uitnodigend groepslid. + U bent uitgenodigd voor de groep. Word lid om in contact te komen met de groepsleden. + Tik om lid te worden + Tik om incognito lid te worden + Je bent lid geworden van deze groep. Verbinding maken met uitnodigend lid. Je hebt een groep uitnodiging verzonden jij bent vertrokken je bent van adres veranderd @@ -802,7 +802,7 @@ Sla het uitnodigen van leden over %d contact(en) geselecteerd Verwijderen - Gebruiker verwijderen + Lid verwijderen Rol Direct bericht sturen De rol wordt gewijzigd in \"%s\". De gebruiker ontvangt een nieuwe uitnodiging. @@ -831,7 +831,7 @@ SimpleX U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. Je profiel wordt verzonden naar het contact van wie je deze link hebt ontvangen. - U wordt lid van de groep waar deze link naar verwijst en maakt verbinding met de groepsleden. + Je maakt verbinding met alle groepsleden. Uitvoeren bij geopende app Toon voorbeeld SimpleX Chat oproepen @@ -949,7 +949,7 @@ %1$d bericht(en) overgeslagen gemodereerd gemodereerd door %s - Bericht van lid verwijderen\? + Bericht van lid verwijderen? Modereren Het bericht wordt verwijderd voor alle leden. Het bericht wordt gemarkeerd als gemodereerd voor alle leden. @@ -1263,7 +1263,7 @@ Annuleer het wijzigen van het adres Afbreken Geen gefilterde gesprekken - Alleen groepseigenaren kunnen bestanden en media inschakelen. + Alleen groep eigenaren kunnen bestanden en media inschakelen. Bestanden en media zijn verboden in deze groep. Favoriet Bestanden en media verboden! @@ -1404,4 +1404,59 @@ Stuur een direct bericht om verbinding te maken stuur een direct bericht direct verbonden + Uitbreiden + Verbindingsverzoek herhalen? + verwijderd contact + Fout + Groep aanmaken + Maak een profiel aan + %s en %s + Deelnemen aan uw groep? + %d berichten gemarkeerd als verwijderd + Groep bestaat al! + Al bezig met verbinden! + De video kan niet worden gedecodeerd. Probeer een andere video of neem contact op met de ontwikkelaars. + %s verbonden + en %d andere gebeurtenissen + Verbinden via link? + Al lid van de groep! + %s, %s en %d leden + %d berichten gemodereerd door %s + Verbinding maken met jezelf? + Tik om verbinding te maken + Juiste naam voor %s? + %d berichten verwijderen? + Verbinding maken met %1$s? + Lid verwijderen + Blokkeren + %d berichten geblokkeerd + Lid blokkeren + Deelnameverzoek herhalen? + Lid verwijderen? + Contact verwijderen en op de hoogte stellen + Open groep + Berichten van %s worden getoond! + Fout bij verzenden van uitnodiging + Lid blokkeren? + %d groep gebeurtenissen + Ongeldige naam! + Ongeldig bestandspad + Console in nieuw venster weergeven + Alle nieuwe berichten van %s worden verborgen! + geblokkeerd + Je bent al verbonden met %1$s. + Je wordt al lid van de groep via deze link. + Je bent al lid van de groep %1$s. + Dit is uw eigen eenmalige link! + Lid deblokkeren + Je zit al in groep %1$s. + Dit is uw eigen SimpleX adres! + Lid deblokkeren? + Je maakt al verbinding via deze eenmalige link! + Je hebt een ongeldig bestandslocatie gedeeld. Rapporteer het probleem aan de app-ontwikkelaars. + Dit is jouw link voor groep %1$s! + Deblokkeren + U heeft al een verbinding aangevraagd via dit adres! + Fout bij heronderhandeling van codering + Opnieuw onderhandelen over de codering is mislukt. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 4e14345e94..e61f040a89 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -9,9 +9,9 @@ łączenie… połączenie ustanowione połączenie %1$d - Połączyć się przez link kontaktowy\? - Połącz się przez link grupowy\? - Połączyć się przez link zapraszający\? + Połączyć się przez adres kontaktowy? + Dołączyć do grupy? + Połączyć się przez link jednorazowy? usunięty błąd nieprawidłowy czat @@ -367,7 +367,7 @@ Usuń adres Usunąć adres\? Usuń obraz - Wyświetlana nazwa: + Nazwa profilu: Edytuj obraz Wyjdź bez zapisywania Pełna nazwa: @@ -723,7 +723,7 @@ Błąd usuwania członka DLA KONSOLI Grupa - Wyświetlana nazwa grupy: + Wprowadź nazwę grupy: Pełna nazwa grupy: Nazwa lokalna CZŁONEK @@ -741,7 +741,7 @@ SERWERY Przełącz Zmień adres odbioru - Grupa jest w pełni zdecentralizowana – jest widoczna tylko dla członków. + W pełni zdecentralizowana – widoczna tylko dla członków. Rola zostanie zmieniona na \"%s\". Członek otrzyma nowe zaproszenie. Wiadomość powitalna Nie można usunąć profilu użytkownika! @@ -1033,7 +1033,7 @@ usunąłeś %1$s Twój profil jest przechowywany na Twoim urządzeniu i udostępniany tylko Twoim kontaktom. Serwery SimpleX nie widzą Twojego profilu. udostępniłeś jednorazowy link incognito - Dołączysz do grupy, do której odnosi się ten link i połączysz się z jej członkami. + Zostaniesz połączony ze wszystkimi członkami grupy. Twoje serwery SMP Zostaniesz połączony, gdy Twoje żądanie połączenia zostanie zaakceptowane, proszę czekać lub sprawdzić później! Błąd ładowania serwerów SMP @@ -1406,4 +1406,59 @@ Wyślij wiadomość bezpośrednią aby połączyć wyślij wiadomość bezpośrednią połącz bezpośrednio + usunięto kontakt + Utwórz grupę + Utwórz profil + %d wiadomości oznaczonych do usunięcia + Już połączony! + i %d innych wydarzeń + Połączyć przez link? + Już dołączono do grupy! + %d wiadomości zmoderowanych przez %s + Połączyć się ze sobą? + Poprawić nazwę do %s? + Usunąć %d wiadomości? + Połączyć z %1$s? + Zablokuj + %d wiadomości zablokowanych + Zablokuj członka + Usuń i powiadom kontakt + Zablokować członka? + %d wydarzeń grupy + Wszystkie nowe wiadomości z %s będą ukryte! + zablokowany + Rozszerz + Powtórzyć prośbę połączenia? + Już jesteś połączony z %1$s. + Błąd + Już dołączasz do grupy przez ten link. + %s i %s + Dołączyć do twojej grupy? + Już dołączasz do grupy %1$s. + To jest twój jednorazowy link! + Grupa już istnieje! + Wideo nie może zostać zdekodowane, spróbuj inne wideo lub skontaktuj się z deweloperami. + %s połączony + %s, %s i %d członków + Odblokuj członka + Dotknij aby połączyć + Już jesteś w grupie %1$s. + To jest twój własny adres SimpleX! + Usuń członka + Odblokować członka? + Powtórzyć prośbę dołączenia? + Usunąć członka? + Już jesteś połączony z tym jednorazowym linkiem! + Grupa otwarta + Wiadomości z %s zostaną wyświetlone! + Błąd wysyłania zaproszenia + Udostępniłeś nieprawidłową ścieżkę pliku. Zgłoś problem do deweloperów aplikacji. + Nieprawidłowa nazwa! + To jest twój link zaproszenia do grupy %1$s! + Odblokuj + Nieprawidłowa ścieżka pliku + Już prosiłeś o połączenie na ten adres! + Pokaż konsolę w nowym oknie + Błąd renegocjacji szyfrowania + Renegocjacja szyfrowania nie powiodła się. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index fbf62d4f35..dd8d9ac857 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -116,11 +116,11 @@ SimpleX Дзвонити можете як ви, так і ваш контакт. k - Підключитися за контактним посиланням\? - Підключитися за посиланням-запрошенням\? - Підключитися за груповим посиланням\? + Підключитися через контактну адресу? + Підключитися за одноразовим посиланням? + Приєднатися до групи? Ваш профіль буде надіслано контакту, від якого ви отримали це посилання. - Ви приєднаєтеся до групи, на яку посилається це посилання, і з\'єднаєтеся з її учасниками. + Ви з\'єднаєтеся з усіма учасниками групи. Підключіться підключений помилка @@ -265,7 +265,7 @@ Повне ім\'я: Ви керуєте своїм чатом! Платформа для обміну повідомленнями та додатків, що захищає вашу конфіденційність і безпеку. - Відображуване ім\'я + Введіть своє ім\'я: виклик у процесі починаючи… Перша платформа без жодних ідентифікаторів користувачів – приватна за дизайном. @@ -411,7 +411,7 @@ Кілька профілів чату Основна версія: v%s Видалити адресу\? - Відображуване ім\'я: + Ім\'я профілю: чекаємо на підтвердження… Переосмислення конфіденційності Люди можуть підключатися до вас лише за посиланнями, якими ви ділитеся. @@ -488,7 +488,7 @@ Видалити посилання Перемикач Помилка, що змінює роль - Відображувана назва групи: + Введіть назву групи: Повна назва групи: Помилка збереження профілю групи Скидання до налаштувань за замовчуванням @@ -643,7 +643,8 @@ Використовуєте сервери SimpleX Chat\? Сервери ICE (по одному на лінію) Помилка збереження серверів ICE - Для з\'єднання будуть потрібні хости onion. + Onion hosts will be required for connection. +\nPlease note: you will not be able to connect to the servers without .onion address. Onion хости будуть використовуватися за наявності. Для з\'єднання будуть потрібні хости onion. Показати опції розробника @@ -1101,7 +1102,7 @@ Введіть вітальне повідомлення… Змінити адресу отримання Створити секретну групу - Група повністю децентралізована - її бачать лише учасники. + Повністю децентралізована - видима лише для учасників. Тайм-аут підключення TCP Більше не показувати Вторинний @@ -1372,4 +1373,90 @@ Переузгодьте шифрування\? Шифрування працює і нова угода про шифрування не потрібна. Це може призвести до помилок з\'єднання! Учасники групи можуть надсилати файли та медіа. + База даних буде зашифрована, а ключова фраза збережена в налаштуваннях. + Розгорнути + Повторити запит на підключення? + Помилка повторного узгодження шифрування + видалений контакт + Ви вже з\'єднані з %1$s. + Відкрити + Шифрування збережених файлів і носіїв + Помилка + Помилка при створенні контакту користувача + Ви вже приєдналися до групи за цим посиланням. + Створити групу + Зверніть увагу: ретранслятори повідомлень і файлів підключаються через проксі SOCKS. Дзвінки та надсилання попередніх переглядів посилань використовують пряме з’єднання.]]> + Створити профіль + %s та %s + Приєднатися до групи? + Ви вже приєдналися до групи %1$s. + Шифрування локальних файлів + Це ваше власне одноразове посилання! + %d повідомлень позначено як видалені + Новий десктопний застосунок! + 6 нових мов інтерфейсу + Група вже існує! + Застосунок шифрує нові локальні файли (крім відео). + Вже під\'єднуємося! + Випадкова фраза зберігається у налаштуваннях у вигляді відкритого тексту. +\nВи можете змінити його пізніше. + Надішліть пряме повідомлення, щоб підключитися + Відео не може бути декодовано. Будь ласка, спробуйте інше відео або зверніться до розробників. + %s підключено + ще інших подій - %d + Знаходьте та приєднуйтесь до груп + Підключитися за посиланням? + Пароль для шифрування бази даних буде оновлено і збережено в налаштуваннях. + Вже долучаємось до групи! + члени %s, %s та %d + %d повідомлень модерує %s + Видалити парольну фразу з налаштувань? + Розблокувати + Використовуйте випадкову парольну фразу + Підключитися до себе? + Зберегти парольну фразу в налаштуваннях + Спрощений режим інкогніто + Натисніть, щоб підключитися + Ключова фраза для налаштування бази даних + Ви вже в групі %1$s. + Це ваша власна SimpleX адреса! + Повторне узгодження шифрування не вдалося. + Виправити ім\'я на %s? + Видалити %d повідомлень? + Підключитися до %1$s? + Видалити учасника + Встановити пароль до бази даних + Заблокувати + Розблокувати учасника? + %d повідомлень заблоковано + Заблокувати учасника + Відкрийте теку з базою даних + Повторити запит на приєднання? + Видалити учасника? + Видалити та повідомити контакт + Арабська, Болгарська, Фінська, Іврит, Тайська та Українська – завдяки користувачам і Weblate. + Ви вже підключаєтеся за цим одноразовим посиланням! + Відкрити групу + Будуть показані повідомлення від %s! + Створіть новий профіль у десктопному застосунку. 💻 + Помилка надсилання запрошення + Після зміни пароля або перезапуску програми він буде збережений у налаштуваннях у вигляді відкритого тексту. + Ви надали невірний шлях до файлу. Повідомте про проблему розробникам програми. + Заблокувати учасника? + %d групових подій + Неправильне ім\'я! + Увімкніть інкогніто при підключенні. + Це ваше посилання для групи %1$s! + Розблокувати + Неправильний шлях до файлу + - підключитися до служби каталогів (БЕТА)! +\n- квитанції про доставку (до 20 учасників). +\n- швидше і стабільніше. + Пароль зберігається у налаштуваннях у вигляді відкритого тексту. + Ви вже надсилали запит на підключення за цією адресою! + надіслати пряме повідомлення + Показати консоль у новому вікні + Всі повідомлення від %s будуть приховані + підключений безпосередньо + заблоковано \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 1d266beaf4..a4f990455f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -62,9 +62,9 @@ 删除消息? 删除消息 连接 - 通过邀请链接进行连接? - 通过联系人链接进行连接? - 通过群组链接连接? + 通过一次性链接进行连接? + 通过联系人地址进行连接? + 加群吗? 通过群组链接/二维码连接 总是通过中继连接 允许您的联系人不可撤回地删除已发送消息。 @@ -358,7 +358,7 @@ 核心版本: v%s 显示名: 全名: - 显示名 + 输入你的名字: 已结束 群组已删除 将为所有成员删除群组——此操作无法撤消! @@ -410,7 +410,7 @@ 保存 ICE 服务器错误 发送消息错误 完整链接 - 群组显示名称: + 输入群组名: 群组邀请已过期 将为您删除群组——此操作无法撤消! 群组资料存储在成员的设备上,而不是服务器上。 @@ -735,7 +735,7 @@ %s 未验证 感谢用户——通过 Weblate 做出贡献! 第一个没有任何用户标识符的平台——专为隐私保护设计。 - 该小组是完全分散式的——它只对成员可见。 + 完全去中心化 - 仅对成员可见。 图像无法解码。 请尝试不同的图像或联系开发者。 主题 此操作无法撤消——所有接收和发送的文件和媒体都将被删除。 低分辨率图片将保留。 @@ -918,7 +918,7 @@ 间接(%1$s) 在移动应用程序中打开按钮。]]> SimpleX - 您将加入此链接指向的群组并连接到其群组成员。 + 你将连接到所有群成员。 通过群组链接 通过一次性链接 通过联系地址链接 @@ -1406,4 +1406,59 @@ 发送私信来连接 发送私信 已直连 + 展开 + 重复连接请求吗? + 已删除联系人 + 你已经在连接到 %1$s。 + 错误 + 你已经在通过此链接加入该群。 + 建群 + 创建个人资料 + %s 和 %s + 加入你的群吗? + 你已经在加入 %1$s 群。 + 这是你自己的一次性链接! + %d 条消息被标记为删除 + 群已存在! + 已经在连接了! + 无法解码该视频。请尝试不同视频或联络开发者。 + %s 已连接 + 及其他 %d 个事件 + 通过链接进行连接吗? + 已经加入了该群组! + %s、 %s 和 %d 名成员 + %s 审核了 %d 条消息 + 解封成员 + 连接到你自己? + 轻按连接 + 你已经在%1$s 群内。 + 这是你自己的 SimpleX 地址! + 更正名称为 %s? + 删除 %d 条消息吗? + 和 %1$s 连接吗? + 删除成员 + 封禁 + 解封成员吗? + 拦截了 %d 条消息 + 封禁成员 + 重复加入请求吗? + 删除成员吗? + 删除并通知联系人 + 你已经在通过这个一次性链接进行连接! + 打开群 + 将显示来自 %s 的消息! + 发送邀请出错 + 你分享了无效的文件路径。请将此问题报告给应用开发者。 + 封禁成员吗? + %d 个群事件 + 无效名称! + 这是给你的 %1$s 群链接! + 解封 + 无效的文件路径 + 你已经请求通过此地址进行连接! + 在新窗口中显示控制台 + 所有来自 %s 的新消息都将被隐藏! + 已封禁 + 加密重协商错误 + 加密重协商失败了。 \ No newline at end of file From bf8457fb40615a494263108424429c44ecfa6a08 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 19 Nov 2023 11:14:09 +0000 Subject: [PATCH 216/294] website: translations (#3396) * Translated using Weblate (French) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/nl/ * Translated using Weblate (Italian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/ * Translated using Weblate (Polish) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ * Translated using Weblate (Russian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ * Translated using Weblate (French) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/nl/ * Translated using Weblate (Italian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/ * Translated using Weblate (Polish) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ * Translated using Weblate (Russian) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ --------- Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: M1K4 Co-authored-by: Random Co-authored-by: Eric Co-authored-by: B.O.S.S Co-authored-by: Timur Bagautdinov --- website/langs/fr.json | 6 +- website/langs/it.json | 6 +- website/langs/nl.json | 6 +- website/langs/pl.json | 15 ++- website/langs/ru.json | 255 ++++++++++++++++++++++++++++++++++++- website/langs/zh_Hans.json | 6 +- 6 files changed, 277 insertions(+), 17 deletions(-) diff --git a/website/langs/fr.json b/website/langs/fr.json index b4d0ea17af..304630a091 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -35,12 +35,12 @@ "hero-overlay-1-title": "Comment fonctionne SimpleX ?", "hero-overlay-2-title": "Pourquoi les IDs d'utilisateur sont nuisibles pour votre vie privée ?", "feature-1-title": "Messages chiffrés E2E avec édition et format markdown", - "feature-2-title": "Images et fichiers
chiffrés E2E", - "feature-3-title": "Groupes secrets décentralisés —
seuls ses membres connaissent leur existence", + "feature-2-title": "Images, vidéos et fichiers
chiffrés E2E", + "feature-3-title": "Groupes chiffrés E2E décentralisés — où seuls les membres en connaissent l'existence", "feature-4-title": "Messages vocaux chiffrés E2E", "feature-5-title": "Conversations secrètes éphémères", "feature-6-title": "Appels audio et vidéo
chiffrés E2E", - "feature-7-title": "Base de données chiffrée portable — déplacez votre profil vers un autre appareil", + "feature-7-title": "Stockage de l'app portable chiffré — déplacez votre profil vers un autre appareil", "feature-8-title": "Mode incognito —
unique à SimpleX Chat", "simplex-network-overlay-1-title": "Comparaison avec des protocoles de messagerie P2P", "simplex-private-1-title": "2 couches de
chiffrement de bout en bout", diff --git a/website/langs/it.json b/website/langs/it.json index b8a83aee52..1f3ad7fdda 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -154,12 +154,12 @@ "home": "Home", "developers": "Sviluppatori", "simplex-explained-tab-1-p-1": "Puoi creare contatti e gruppi, ed avere conversazioni bidirezionali, come in qualsiasi altro messenger.", - "feature-2-title": "Immagini e file
crittografati E2E", + "feature-2-title": "Immagini, video e file
crittografati E2E", "simplex-private-card-4-point-2": "Per usare SimpleX tramite Tor, installa l'app Orbot e attiva il proxy SOCKS5 (o VPN su iOS).", "reference": "Riferimenti", "simplex-explained-tab-3-text": "3. Cosa vedono i server", "chat-bot-example": "Esempio di chat bot", - "feature-3-title": "Gruppi segreti decentralizzati —
solo gli utenti sanno che esistono", + "feature-3-title": "Gruppi decentralizzati crittografati E2E — solo gli utenti sanno che esistono", "blog": "Blog", "simplex-explained-tab-2-p-1": "Per ogni connessione usi due code di messaggi distinte per inviare e ricevere i messaggi attraverso server diversi.", "simplex-explained-tab-2-p-2": "I server passano i messaggi solo in una direzione, senza avere il quadro completo della conversazione dell'utente o delle connessioni.", @@ -169,7 +169,7 @@ "feature-5-title": "Conversazioni segrete a tempo", "feature-6-title": "Chiamate audio e video
crittografate E2E", "simplex-private-6-title": "Scambio di chiavi
fuori banda", - "feature-7-title": "Database crittografato trasferibile — sposta il tuo profilo su un altro dispositivo", + "feature-7-title": "Archiviazione dell'app crittografata e trasferibile — sposta il profilo su un altro dispositivo", "simplex-private-card-1-point-2": "Cryptobox NaCL in ogni coda per evitare correlazioni di traffico tra code di messaggi se il TLS è compromesso.", "simplex-private-card-3-point-3": "Ripresa della connessione disattivata per evitare attacchi alla sessione.", "hero-overlay-card-1-p-4": "Questo design impedisce la fuoriuscita di metadati degli utenti a livello di applicazione. Per aumentare ulteriormente la privacy e proteggere il tuo indirizzo IP puoi connetterti ai server di messaggistica tramite Tor.", diff --git a/website/langs/nl.json b/website/langs/nl.json index 0cb3428563..51c5855b51 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -28,8 +28,8 @@ "hero-overlay-1-title": "Hoe werkt SimpleX?", "hero-overlay-2-title": "Waarom zijn gebruikers-ID's slecht voor de privacy?", "feature-1-title": "E2E-versleutelde berichten met markdown en bewerking", - "feature-2-title": "E2E-versleutelde
afbeeldingen en bestanden", - "feature-3-title": "Gedecentraliseerde geheime groepen -
alleen gebruikers weten dat ze bestaan", + "feature-2-title": "E2E-versleutelde
afbeeldingen,videos en bestanden", + "feature-3-title": "E2E-gecodeerde gedecentraliseerde groepen - alleen gebruikers weten dat ze bestaan", "feature-4-title": "E2E-versleutelde spraakberichten", "feature-5-title": "Verdwijnende geheime gesprekken", "feature-6-title": "E2E-versleutelde
audio- en videogesprekken", @@ -60,7 +60,7 @@ "hero-p-1": "Andere apps hebben gebruikers-ID's: Signal, Matrix, Session, Briar, Jami, Cwtch, enz.
SimpleX niet, zelfs geen willekeurige getallen.
Dit verbetert uw privacy.", "hero-2-header-desc": "De video laat zien hoe je verbinding maakt met een vriend via een persoonlijk of videolink gedeelde eenmalige QR-code. U kunt ook verbinding maken door een uitnodigingslink te delen.", "hero-header": "Privacy opnieuw gedefinieerd", - "feature-7-title": "Portable versleutelde database — verplaats je profiel naar een ander apparaat", + "feature-7-title": "Draagbare gecodeerde app-opslag - profiel naar een ander apparaat verplaatsen", "simplex-private-card-1-point-1": "Protocol met double-ratchet -
OTR-berichten met perfecte voorwaartse geheimhouding en inbraak herstel.", "simplex-private-6-title": "Out-of-band
sleuteluitwisseling", "simplex-private-7-title": "Bericht integriteit
verificatie", diff --git a/website/langs/pl.json b/website/langs/pl.json index f9b9936848..ba0c72da7e 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -27,11 +27,11 @@ "hero-overlay-1-title": "Jak działa SimpleX?", "hero-overlay-2-title": "Dlaczego identyfikatory użytkowników (ID) są złe dla prywatności?", "feature-1-title": "Wiadomości szyfrowane przez E2E ze składnią markdown i edycją", - "feature-3-title": "Zdecentralizowane tajne grupy —
tylko użytkownicy wiedzą, że te grupy istnieją", + "feature-3-title": "Zaszyfrowane E2E zdecentralizowane grupy — tylko użytkownicy wiedzą, że te grupy istnieją", "feature-4-title": "Wiadomości głosowe zaszyfrowane przez E2E", "feature-5-title": "Znikające wiadomości", "feature-6-title": "Połączenia audio i wideo
szyfrowane przez E2E", - "feature-7-title": "Przenośna zaszyfrowana baza danych — przenieś swój profil na inne urządzenie", + "feature-7-title": "Przenośna zaszyfrowana pamięć aplikacji — przenieś profil na inne urządzenie", "simplex-private-4-title": "Możliwy
dostęp za pośrednictwem sieci Tor", "simplex-private-5-title": "Wielowarstwowy
padding treści", "simplex-private-6-title": "Wymiana kluczy
out-of-band", @@ -49,7 +49,7 @@ "simplex-explained-tab-2-p-2": "Serwery przekazują wiadomości tylko w jedną stronę, nie mając pełnego obrazu konwersacji i połączeń użytkownika.", "simplex-explained-tab-3-p-2": "Użytkownicy mogą jeszcze bardziej zwiększyć prywatność metadanych, używając Tor do uzyskania dostępu do serwerów, co zapobiega korelacji na podstawie adresu IP.", "hero-p-1": "Inne aplikacje mają identyfikatory użytkowników (ID): Signal, Matrix, Session, Briar, Jami, Cwtch itp.
SimpleX nie, nie ma nawet losowych numerów.
To radykalnie poprawia Twoją prywatność.", - "feature-2-title": "Obrazy i pliki
zaszyfrowane przez E2E", + "feature-2-title": "Obrazy, wideo i pliki
zaszyfrowane przez E2E", "feature-8-title": "Tryb incognito —
unikalny dla SimpleX Chat", "simplex-network-overlay-1-title": "Porównanie z protokołami komunikacyjnymi P2P", "simplex-private-1-title": "Dwie warstwy
szyfrowania typu end-to-end", @@ -243,5 +243,12 @@ "f-droid-org-repo": "Repo F-Droid.org", "stable-versions-built-by-f-droid-org": "Wersje stabilne zbudowane przez F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "Wydania na tym repo są 1-2 dni później", - "comparison-section-list-point-4a": "Przekaźniki SimpleX nie mogą skompromitować szyfrowania e2e. Zweryfikuj kody bezpieczeństwa aby złagodzić atak na kanał pozapasmowy" + "comparison-section-list-point-4a": "Przekaźniki SimpleX nie mogą skompromitować szyfrowania e2e. Zweryfikuj kody bezpieczeństwa aby złagodzić atak na kanał pozapasmowy", + "hero-overlay-3-title": "Ocena bezpieczeństwa", + "hero-overlay-card-3-p-2": "Trail of Bits przejrzał komponenty kryptograficzne i sieciowe platformy SimpleX w listopadzie 2022.", + "hero-overlay-card-3-p-3": "Przeczytaj więcej w ogłoszeniach.", + "jobs": "Dołącz do zespołu", + "hero-overlay-3-textlink": "Ocena bezpieczeństwa", + "hero-overlay-card-3-p-1": "Trail of Bits jest wiodącą firmą konsultingową w zakresie bezpieczeństwa i technologii, której klientami są duże firmy technologiczne, agencje rządowe i główne projekty blockchain.", + "docs-dropdown-9": "Pliki do pobrania" } diff --git a/website/langs/ru.json b/website/langs/ru.json index 0967ef424b..73fba95480 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -1 +1,254 @@ -{} +{ + "copy-the-command-below-text": "скопируйте приведенную ниже команду и используйте ее в чате:", + "copyright-label": "© 2020-2023 SimpleX | Проект с открытым исходным кодом", + "chat-bot-example": "Пример Чат бота", + "simplex-private-card-9-point-1": "Каждая очередь сообщений передает сообщения в одном направлении с разными адресами отправки и получения.", + "simplex-private-card-1-point-2": "Криптобокс NaCL в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометации TLS.", + "contact-hero-p-1": "Открытые ключи и адрес очереди сообщений по этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хэш-фрагменте URL-адреса ссылки.", + "guide-dropdown-5": "Управление данными", + "scan-the-qr-code-with-the-simplex-chat-app": "Отсканируйте QR-код с помощью приложения SimpleX Chat", + "simplex-private-card-9-point-2": "Это уменьшает векторы атак и доступные метаданные, по сравнению с традиционными посредниками для доставки сообщений.", + "simplex-unique-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", + "feature-7-title": "Портативное, зашифрованное хранилище в приложении — можно перенести профиль на другое устройство", + "no-federated": "Нет - федеративный", + "hero-2-header": "Как начать общаться приватно", + "simplex-unique-overlay-card-3-p-3": "В отличие от серверов федеративных сетей (электронной почты, XMPP или Matrix), серверы SimpleX не хранят учетные записи пользователей, они только ретранслируют сообщения, защищая конфиденциальность обеих сторон.", + "hero-subheader": "Первый мессенджер
не нуждающийся в идентификаторах
пользователя", + "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй - это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистан под подозревается в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", + "signing-key-fingerprint": "Отпечаток ключа подписи (SHA-256)", + "simplex-network-2-desc": "Серверные узлы SimpleX НЕ хранят профили пользователей, контакты и доставленные сообщения, НЕ подключаются друг к другу, и НЕ имеют каталога серверов.", + "simplex-privacy": "Конфиденциальность SimpleX", + "docs-dropdown-5": "Свой XFTP Сервер", + "simplex-private-card-3-point-2": "Отпечаток сервера и привязка канала предотвращают MITM атаки и Атаки повторного воспроизведения.", + "docs-dropdown-3": "Доступ к в базе данных чата", + "installing-simplex-chat-to-terminal": "Установка SimpleX Chat в терминале", + "use-this-command": "Используйте эту команду:", + "simplex-explained": "Простое объяснение вкратце", + "to-make-a-connection": "Чтобы установить соединение:", + "comparison-section-list-point-6": "Хотя P2P распределены, они не являются федеративными, то есть P2P - работают как единая сеть", + "hero-overlay-2-textlink": "Как работает SimpleX?", + "simplex-chat-via-f-droid": "SimpleX Chat в F-Droid", + "privacy-matters-overlay-card-1-p-1": "Многие крупные компании используют информацию о том, с кем вы связаны, чтобы оценить ваш доход, продавать вам больше товаров, которые вам на самом деле не нужны, и определять из этой информации, выгодные для них цены.", + "privacy-matters-1-overlay-1-title": "Конфиденциальность экономит ваши деньги", + "simplex-private-card-5-point-1": "SimpleX использует заполнение содержимого для каждого уровня шифрования, чтобы предотвратить атаки на размер сообщения.", + "privacy-matters-2-overlay-1-linkText": "Конфиденциальность дает вам власть", + "hero-overlay-3-title": "Оценка безопасности", + "enter-your-email-address": "Email адрес", + "simplex-explained-tab-1-text": "1. Как это видят пользователи", + "tap-to-close": "Нажмите, чтобы закрыть", + "simplex-unique-card-4-p-2": "Вы можете использовать SimpleX со своими собственными серверами или с серверами, предоставленными нами — и при этом подключаться к любому пользователю SimpleX.", + "hero-overlay-card-3-p-2": "В ноябре 2022 года Trail of Bits провела обзор криптографии и сетевых компонентов SimpleX.", + "feature-1-title": "Сообщения зашифрованные E2E-шифрованием
с поддержкой markdown и редактированием", + "comparison-point-4-text": "Единая или Централизованная сеть", + "guide-dropdown-9": "Установление соединений", + "simplex-unique-1-overlay-1-title": "Полная конфиденциальность вашей личности, профиля, контактов и других метаданных", + "hero-overlay-card-2-p-4": "SimpleX защищает от этих атак, поскольку в его конструкции нет никаких идентификаторов пользователей. И, если вы используете режим инкогнито, у вас будет другое отображаемое имя для каждого контакта, что позволит избежать какого-либо пересечения между ними.", + "privacy-matters-overlay-card-2-p-2": "Чтобы быть объективным и принимать независимые решения, вам необходимо контролировать свое информационное пространство. Это возможно только в том случае, если вы используете приватную, коммуникационную платформу, которая не имеет доступа к вашему социальному графу.", + "hero-overlay-card-2-p-1": "Когда у пользователей есть постоянные идентификаторы, даже если это просто случайное число, например идентификатор сеанса, существует риск того, что провайдер или злоумышленник могут наблюдайте за тем, как подключены пользователи и сколько сообщений они отправляют.", + "feature-3-title": "Децентрализованные группы — только
их участники знают, что они существуют", + "glossary": "Глоссарий", + "simplex-network-overlay-1-title": "Сравнение с протоколами обмена сообщениями P2P", + "comparison-section-list-point-7": "Сети P2P либо имеют центральный орган управления, либо вся сеть может быть скомпрометирована", + "simplex-unique-overlay-card-4-p-1": "Вы можете использовать SimpleX со своими собственными серверами или предоставленными нами серверами, при этом имея возможность общаться с любым пользователем.", + "simplex-explained-tab-3-p-1": "Серверы имеют отдельные, Анонимные учётные данные для каждой очереди и не знают, к каким пользователям они принадлежат.", + "docs-dropdown-1": "Платформа SimpleX", + "hero-overlay-card-1-p-5": "Только клиентские устройства хранят профили пользователей, контакты и группы; сообщения отправляются с двухуровневым, Сквозным шифрованием.", + "simplex-chat-for-the-terminal": "SimpleX Chat для терминала", + "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и большинство существующих реализаций не используют внеполосные сообщения для первоначального обмена ключами. SimpleX использует внеполосные сообщения или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", + "the-instructions--source-code": "SimpleX Chat.", + "simplex-network-section-desc": "SimpleX Chat обеспечивает наилучшую конфиденциальность, сочетая преимущества P2P и федеративных сетей.", + "privacy-matters-section-subheader": "Сохранение конфиденциальности ваших метаданных — с кем вы общаетесь — защищает вас от:", + "if-you-already-installed": "Если вы уже установили", + "simplex-explained-tab-3-p-2": "Пользователи могут еще больше повысить свою конфиденциальность скрыв свой IP-адрес, например используя сеть Tor для доступа к серверам.", + "join": "Присоединяйся к", + "privacy-matters-section-header": "Почему приватность важна", + "hero-overlay-1-textlink": "Почему идентификаторы пользователя - вредны для приватности?", + "on-this-page": "На этой странице", + "privacy-matters-overlay-card-1-p-2": "Интернет-магазины знают, что люди с более низкими доходами с большей вероятностью совершают срочные покупки, поэтому они могут устанавливать более высокие цены или отменять скидки.", + "simplex-unique-3-overlay-1-title": "Контроль и безопасность ваших данных", + "protocol-3-text": "Протоколы P2P", + "simplex-private-card-6-point-2": "Чтобы предотвратить это, приложения SimpleX передают одноразовые ключи внеполосно, когда вы делитесь адресом в виде ссылки или QR-кода.", + "no": "Нет", + "contact-hero-header": "Вы получили адрес для подключения в SimpleX Chat", + "feature-8-title": "Режим инкогнито —
уникальный для SimpleX Chat", + "why-simplex": "Что делает SimpleX уникальным", + "simplex-private-card-4-point-2": "Чтобы использовать SimpleX через сеть Tor, пожалуйста, установите приложение Orbot и включите прокси (или режим VPN на iOS).", + "contact-hero-subheader": "Отсканируйте QR-код с помощью приложения SimpleX Chat на вашем телефоне или планшете.", + "simplex-unique-2-overlay-1-title": "Лучшая защита от спама и злоупотреблений", + "simplex-private-6-title": "Внеполосный
Обмен ключами", + "join-us-on-GitHub": "Присоединяйтесь к нам на GitHub", + "hero-overlay-card-3-p-3": "Подробнее читайте в анонсе.", + "comparison-section-header": "Сравнение с другими протоколами", + "invitation-hero-header": "Вы получили одноразовую ссылку для подключения в SimpleX Chat", + "no-secure": "Нет - безопасно", + "hero-overlay-card-1-p-2": "Для доставки сообщений вместо идентификаторов пользователей, используемых всеми другими платформами, SimpleX использует временные, анонимные, попарные идентификаторы очередей сообщений, отдельно для каждого из ваших контактов — нет долгосрочных идентификаторов.", + "simplex-explained-tab-1-p-2": "Как это может работать с однонаправленными очередями и без идентификаторов профиля пользователя?", + "simplex-network-1-header": "В отличие от P2P-сетей", + "jobs": "Присоединиться к команде", + "simplex-private-card-7-point-2": "Если какое-либо сообщение будет добавлено, удалено или изменено, получатель будет предупрежден об этом.", + "simplex-unique-3-title": "Только вы контролируете
свои данные", + "guide-dropdown-3": "Секретные группы", + "no-resilient": "Нет - устойчив", + "hide-info": "Спрятать информацию", + "privacy-matters-overlay-card-3-p-4": "Недостаточно просто использовать мессенджер со сквозным шифрованием, мы все должны использовать мессенджеры, которые защищают конфиденциальность наших личных сетей — с какими людьми мы связаны.", + "releases-to-this-repo-are-done-1-2-days-later": "Выпуск новых версий в этом репозитории выходит с задержкой в 1-2 дня", + "comparison-point-1-text": "Требуется глобальный идентификатор", + "comparison-section-list-point-5": "Не защищает конфиденциальность пользовательских метаданных", + "hero-overlay-card-2-p-2": "Затем они могли бы сопоставить эту информацию с существующими общедоступными социальными сетями и определить некоторые реальные личности.", + "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные графики для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"премия за бедность\".", + "comparison-point-3-text": "Зависимость от DNS", + "yes": "Да", + "docs-dropdown-6": "Сервера WebRTC", + "newer-version-of-eng-msg": "Существует более новая версия этой страницы на английском языке.", + "install-simplex-app": "Установите приложение SimpleX", + "hero-overlay-3-textlink": "Оценка безопасности", + "comparison-point-2-text": "Возможность MITM", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Открытые ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хэш-фрагменте URL-адреса ссылки.", + "guide-dropdown-8": "Настройки приложения", + "simplex-explained-tab-2-p-2": "Серверы передают сообщения только в одну сторону, не имея полной картины общения пользователя или подключений.", + "smp-protocol": "Протокол SMP", + "open-simplex-app": "Откройте приложение SimpleX", + "see-simplex-chat": "Инструкции по загрузке или компиляции SimpleX Chat из исходного кода приведены в", + "terminal-cli": "Приложение для терминала (CLI)", + "comparison-section-list-point-1": "Обычно требуется номера телефона, в некоторых случаях — имя пользователя", + "simplex-explained-tab-3-text": "3. Что видят сервера", + "github-repository": "репозиторий Github", + "feature-5-title": "Исчезающие сообщения", + "connect-in-app": "Подключитесь в приложении", + "menu": "Меню", + "simplex-private-card-4-point-1": "Чтобы защитить свой IP-адрес вы можете подключаться к серверам через сеть Tor или какую-либо другую транспортную оверлейную сеть.", + "privacy-matters-3-title": "Судебное преследование не виновных", + "comparison-point-5-text": "Атака на центральный компонент или другая сетевая атака", + "click-to-see": "Нажать здесь, чтобы увидеть", + "donate-here-to-help-us": "Пожертвуйте здесь, чтобы помочь нам", + "simplex-private-1-title": "2-уровневое
сквозное шифрование", + "simplex-unique-card-1-p-2": "В отличие от любой другой существующей платформы обмена сообщениями, SimpleX не имеет идентификаторов пользователей — нету даже случайных цифр.", + "privacy-matters-2-overlay-1-title": "Конфиденциальность дает вам власть", + "simplex-unique-overlay-card-2-p-2": "Хоть злоумышленники и могут использовать постоянный адрес для отправки нежелательных запросов или спама, вы можете легко его изменить или просто удалить, не теряя связи с уже установленными контактами.", + "simplex-unique-4-overlay-1-title": "Полностью децентрализованная — пользователи владеют сетью SimpleX", + "guide-dropdown-2": "Отправка сообщений", + "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по его смягчению требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не имеет функционала по обмену серверами, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку по всей сети - невозможно.", + "simplex-private-2-title": "Дополнительный уровень
шифрования сервера", + "hero-overlay-card-1-p-4": "Такая конструкция предотвращает утечку любых пользовательских метаданных на уровне приложения. Для дальнейшего улучшения конфиденциальности и защиты вашего IP-адреса вы можете подключиться к серверам обмена сообщениями через сеть Tor.", + "f-droid-org-repo": "Репозиторий F-Droid.org", + "guide-dropdown-4": "Профили чата", + "simplex-network-2-header": "В отличие от федеративных сетей", + "see-here": "подробней тут", + "simplex-private-3-title": "Безопасный аутентифицированный
протокол TLS", + "comparison-section-list-point-3": "Открытый ключ или какой-либо другой глобально уникальный идентификатор", + "hero-overlay-card-2-p-3": "Даже в самых приватных приложениях, использующих скрытые сервисы Tor v3, если вы общаетесь с двумя разными контактами через один и тот же профиль, они могут доказать, что они являются связаны с одним и тем же человеком.", + "simplex-private-4-title": "Вариант доступа
через сеть Tor", + "privacy-matters-1-title": "Реклама и ценовая дискриминация", + "simplex-unique-card-3-p-1": "SimpleX хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных — их можно перенести на другое устройство.", + "hero-overlay-1-title": "Как работает SimpleX?", + "stable-versions-built-by-f-droid-org": "Стабильные версии, созданные F-Droid.org", + "contact-hero-p-3": "Воспользуйтесь ссылками ниже, чтобы загрузить приложение.", + "simplex-network": "Сеть SimpleX", + "privacy-matters-3-overlay-1-title": "Конфиденциальность защищает вашу свободу", + "docs-dropdown-7": "Перевести SimpleX Chat", + "back-to-top": "Вернуться к началу", + "simplex-network-1-desc": "Все сообщения отправляются через серверы, что обеспечивает лучшую конфиденциальность метаданных и надежную асинхронную доставку сообщений, избегая при этом многих", + "simplex-chat-repo": "Репозиторий SimpleX Chat", + "simplex-private-card-6-point-1": "Многие коммуникационные платформы уязвимы для MITM-атак со стороны серверов или сетевых провайдеров.", + "privacy-matters-3-overlay-1-linkText": "Конфиденциальность защищает вашу свободу", + "simplex-unique-overlay-card-1-p-2": "Для доставки сообщений SimpleX использует попарные, анонимные адреса однонаправленных очередей сообщений, раздельные для полученных и отправленных сообщений, обычно через разные серверы. Использование SimpleX это как иметь отдельный “одноразовый” адрес электронной почты или номер телефона для каждого контакта, при это, не обременяя вас управлять эти вручную.", + "simplex-unique-overlay-card-3-p-4": "Со стороны не видно разницу между отправлением или получением сообщений — если кто-то наблюдает за этим, он не cможет легко определить, кто с кем общается, даже если протокол TLS будет скомпрометирован.", + "docs-dropdown-2": "Доступ к файлам в версии для Android", + "get-simplex": "Скачать SimpleX для ПК", + "privacy-matters-overlay-card-3-p-1": "Каждый должен заботиться о конфиденциальности и безопасности своих коммуникаций — безобидные разговоры могут подвергнуть вас опасности, например за ваши политические взгляды, даже если кажется, что вам \"нечего скрывать\".", + "simplex-unique-2-title": "Вы защищены от
спама и злоупотреблений", + "simplex-unique-overlay-card-4-p-3": "Если вы рассматриваете возможность разработки платформе SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", + "comparison-section-list-point-2": "Адреса на основе DNS", + "stable-and-beta-versions-built-by-developers": "Стабильные и бета-версии, созданные разработчиками", + "simplex-network-3-header": "Сеть SimpleX", + "simplex-unique-card-2-p-1": "Поскольку у вас нет идентификатора или фиксированного адреса на платформе SimpleX, никто не сможет связаться с вами, без вашего явного согласия, только если вы сами поделитесь адресом в виде QR-кода или ссылки.", + "hero-overlay-card-3-p-1": "Trail of Bits - ведущая консалтинговая компания в области безопасности и технологий, клиентами которой являются крупные технологические компании, правительственные агентства и крупные блокчейн проекты.", + "hero-header": "Иной взгляд на приватность", + "comparison-section-list-point-4": "Если операторы серверов скомпрометированы. В Signal, и некоторых других приложениях, есть возможность подтвердить код безопасности", + "simplex-private-card-2-point-1": "Дополнительный уровень серверного шифрования для доставки получателю, чтобы предотвратить корреляцию между полученным и отправленным трафиком сервера, если используемый протокол TLS скомпрометированный.", + "f-droid-page-simplex-chat-repo-section-text": "Чтобы добавить его в свой клиент F-Droid, отсканируйте QR-код или воспользуйтесь этим URL-адресом:", + "guide-dropdown-7": "Приватность и безопасность", + "join-the-REDDIT-community": "Присоединяйтесь к нам в сообществе REDDIT", + "simplex-private-card-10-point-2": "Это позволяет доставлять сообщения без идентификаторов профиля пользователя, обеспечивая лучшую конфиденциальность метаданных, чем другие альтернативы.", + "privacy-matters-2-title": "Манипулирование выборами", + "home": "Домашняя страница", + "chat-protocol": "Протокол чата", + "simplex-private-card-5-point-2": "Это позволяет сообщениям разного размера выглядеть одинаково для серверов и сетевых наблюдателей.", + "hero-overlay-card-1-p-1": "Многие спрашивают:Если у SimpleX нету никаких идентификаторов пользователя, то как приложение знает, куда доставлять сообщения?", + "feature-6-title": "Зашифрованные E2E-шифрованием аудио и видео звонки", + "hero-p-1": "Другие приложения имеют ID своих пользователей: Signal, Matrix, Session, Briar, Jami, Cwtch и т. п.
SimpleX не имет, нету даже случайных цифр.
Это значительно повышает вашу приватность.", + "simplex-network-overlay-card-1-li-2": "В отличие от многих P2P сетей, SimpleX спроектирован так, чтобы не нуждаться в глобальных идентификаторов его пользователей, даже временных, используя только временные попарные идентификаторы, обеспечивая лучшую анонимность и защиту метаданных пользователя.", + "simplex-unique-4-title": "Только вы владеете
сетью SimpleX", + "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", + "simplex-unique-overlay-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", + "blog": "Новости", + "simplex-private-card-7-point-1": "Чтобы гарантировать целостность, сообщения последовательно нумеруются и включают в себя хэш предыдущего сообщения.", + "simplex-unique-overlay-card-4-p-2": "Платформа SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем возможности увидеть, какие сервисы SimpleX вы сможете создать.", + "simplex-explained-tab-1-p-1": "Вы можете создавать контакты и группы, а также вести двусторонние беседы, как и в любом другом мессенджере.", + "contact-hero-p-2": "Еще не скачали SimpleX Chat?", + "why-simplex-is": "Почему SimpleX", + "simplex-network-section-header": "Сеть SimpleX", + "simplex-private-10-title": "Временные анонимные парные идентификаторы", + "privacy-matters-1-overlay-1-linkText": "Конфиденциальность экономит ваши деньги", + "tap-the-connect-button-in-the-app": "Нажмите на кнопку ’подключиться’ в приложении", + "comparison-section-list-point-4a": "Сервера SimpleX не могут скомпрометировать сквозное шифрование", + "unique": "уникальный", + "simplex-network-1-overlay-linktext": "проблем P2P сетей", + "no-private": "Нет - приватно", + "simplex-unique-1-title": "У вас есть полная
конфиденциальность", + "protocol-2-text": "XMPP, Matrix", + "guide": "Руководство", + "simplex-network-overlay-card-1-li-4": "Реализации P2P могут быть заблокированы некоторыми интернет-провайдерами (например, BitTorrent). SimpleX не зависит от транспорта - он может работать по стандартным веб-протоколам, например WebSockets.", + "hero-overlay-2-title": "Почему идентификаторы пользователя - вредны для приватности?", + "simplex-explained-tab-2-text": "2. Как это работает", + "docs-dropdown-4": "Свой SMP Сервер", + "feature-4-title": "Зашифрованные E2E-шифрованием голосовые сообщения", + "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши социальные графики для искажения нашего мнения из реального мира и манипулируют нашими голосами.", + "privacy-matters-overlay-card-2-p-3": "SimpleX - это первая платформа, которая по своей конструкции не имеет никаких идентификаторов пользователей, таким образом защищая график ваших контактов лучше, чем любая известная альтернатива.", + "learn-more": "Учить больше", + "donate": "Пожертвовать", + "simplex-private-8-title": "Смешивание сообщений
для уменьшения корреляции", + "scan-qr-code-from-mobile-app": "Отсканируйте QR-код в мобильном приложении", + "simplex-private-card-3-point-3": "Возобновление соединения отключено для предотвращения сеансовых атак.", + "simplex-private-card-10-point-1": "SimpleX использует временные анонимные попарные адреса и учетные данные для каждого контакта пользователя или члена группы.", + "guide-dropdown-6": "Аудио и видео Звонки", + "more-info": "Больше информации", + "no-decentralized": "Нет - децентрализованный", + "protocol-1-text": "Signal, большие платформы", + "hero-2-header-desc": "В видео показано, как подключиться к своему другу с помощью его одноразового QR-кода, лично или по видеосвязи. Вы также можете подключиться, поделившись ссылкой-приглашением.", + "simplex-network-overlay-card-1-li-6": "Сети P2P могут быть уязвимы для DRDoS атаки, когда клиенты могут ретранслировать и усиливать/увеличивать объём трафика, что приводит к отказу в обслуживании по всей сети. Клиенты SimpleX ретранслируют трафик только из известного соединения и не могут быть использованы злоумышленником для нагрузки трафика всей сети.", + "if-you-already-installed-simplex-chat-for-the-terminal": "Если вы уже установили SimpleX Chat для терминала", + "docs-dropdown-8": "Служба Каталогов SimpleX", + "simplex-private-card-1-point-1": "Протокол с двойным храповым механизмом —
обмен сообщениями OTR с идеальной секретностью пересылки и восстановлением после взлома.", + "simplex-private-card-8-point-1": "Серверы SimpleX действуют как узлы-миксеры с низкой задержкой — входящие и исходящие сообщения имеют разный порядок.", + "simplex-unique-overlay-card-2-p-1": "Поскольку у вас нет идентификатора на платформе SimpleX, никто не сможет связаться с вами, если вы сами не предоставите одноразовый или временный адрес в виде QR-кода или ссылки.", + "sign-up-to-receive-our-updates": "Введите ваш Email, чтобы получать рассылку обновлений от нас", + "guide-dropdown-1": "Быстрый старт", + "simplex-explained-tab-2-p-1": "Для каждого подключения вы используете две отдельные очереди обмена сообщениями, то есть отправка и получения сообщений происходит через разные серверы.", + "simplex-private-section-header": "Что делает SimpleX приватным", + "we-invite-you-to-join-the-conversation": "Мы приглашаем вас присоединиться к беседе", + "feature-2-title": "Изображения, видео и файлы
зашифрованные E2E-шифрованием", + "simplex-private-9-title": "Однонаправленные
очереди сообщений", + "simplex-unique-overlay-card-1-p-3": "Этот дизайн защищает конфиденциальность того, с кем вы общаетесь, скрывая это от серверов SimpleX и от любых наблюдателей из вне. Чтобы скрыть свой IP-адрес от серверов, вы можете подключиться к серверам SimpleX через сеть Tor.", + "developers": "Разработчики", + "simplex-private-7-title": "Проверка целостности
сообщения", + "privacy-matters-overlay-card-1-p-4": "Платформа SimpleX защищает конфиденциальность ваших контактов лучше, чем любая другая альтернатива, полностью предотвращая доступ к вашему социальному графику каким-либо компаниям или организациям. Даже когда люди используют серверы, предоставляемые SimpleX Chat, мы не знаем точное количество пользователей или с кем они общаются.", + "hero-overlay-card-1-p-6": "Подробнее читайте в техническом документе SimpleX.", + "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", + "terms-and-privacy-policy": "Условия & Политика Конфиденциальности", + "simplex-network-overlay-card-1-li-1": "Сети P2P полагаются на тот или иной вариант DHT для маршрутизации сообщений. Проекты DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети - O(log N).", + "privacy-matters-section-label": "Убедитесь, что ваш мессенджер не может получить доступ к вашим данным!", + "simplex-unique-overlay-card-3-p-1": "SimpleX Chat хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных которую можно перенести на другое устройство.", + "simplex-network-3-desc": "серверы предоставляют однонаправленные очереди для подключения пользователей, но у них нет видимости графика сетевых подключений — это делают только пользователи.", + "simplex-unique-card-1-p-1": "SimpleX защищает конфиденциальность вашего профиля, контактов и метаданных, скрывая их от серверов платформы SimpleX и любых наблюдателей.", + "simplex-private-card-3-point-1": "Для соединений клиент-сервер используется только протокол TLS 1.2/1.3 с надежными алгоритмами.", + "simplex-unique-card-4-p-1": "Сеть SimpleX полностью децентрализована и независима от любой криптовалюты/блокчейна или любой другой платформы, кроме Интернета.", + "features": "Возможности", + "hero-overlay-card-1-p-3": "Вы определяете, какие серверы будете использовать для получения сообщений, а ваши контакты — серверы, которые вы используете для отправки им сообщений. Каждый новый чат, скорее всего, будет вестись на двух разных серверах.", + "docs-dropdown-9": "Скачать", + "simplex-chat-protocol": "Протокол SimpleX Chat", + "simplex-unique-overlay-card-1-p-1": "В отличие от других платформ обмена сообщениями, SimpleX не имеет идентификаторов, присвоенных пользователям. Он не полагается на номера телефонов, доменные адреса (например, электронную почту или XMPP), имена пользователей, открытые ключи или даже случайные числа для идентификации своих пользователей — мы не знаем, сколько людей пользуются нашими SimpleX серверами.", + "reference": "Ссылки", + "f-droid-page-f-droid-org-repo-section-text": "Приложение SimpleX Chat от разработчиков и от репозитория F-Droid.org имеют разные ключи подписи. Если вы хотите сменить одно на другое, вам сначала нужно будет экспортировать базу данных и только потом скачать другое приложение.", + "simplex-private-5-title": "Многоуровневое
Заполнения содержимого" +} diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 78e55a1f72..8fb012963a 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -65,13 +65,13 @@ "hero-overlay-2-textlink": "SimpleX 是如何工作的?", "hero-2-header-desc": "右侧的视频向您展示了如何通过一次性二维码、面对面交流或通过视频交换链接来连接到您的朋友。您同样可以通过共享邀请链接来进行连接。", "hero-overlay-1-title": "SimpleX 是如何工作的?", - "feature-2-title": "端到端加密的图像和文件", + "feature-2-title": "端到端加密的
图片、视频和文件", "feature-1-title": "端到端加密的文字消息,支持markdown和编辑", "feature-4-title": "端到端加密的语音消息", - "feature-3-title": "去中心化的秘密群组——只有用户知道它们的存在", + "feature-3-title": "端到端加密的去中心化的秘密群组 —只有用户知道它们的存在", "feature-5-title": "支持消息自动销毁", "simplex-network-overlay-1-title": "与 P2P 通讯协议的比较", - "feature-7-title": "随身的加密数据库——可将您的个人资料移至另一台设备", + "feature-7-title": "便携的加密应用存储— 可将您的个人资料移至另一台设备", "simplex-private-10-title": "临时匿名成对标识符", "simplex-private-8-title": "通过消息混合减少相关性", "simplex-private-card-1-point-2": "每个队列中的网络与密码学库加密盒(NaCL cryptobox)可防止 TLS 受到威胁时消息队列之间的流量关联。", From 2a8d7b8926760045ae1120fdeacfb8dc3087b87f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 19 Nov 2023 20:48:25 +0000 Subject: [PATCH 217/294] core: add commands that will not be forwarded to connected mobile (#3398) * core: add commands that will not be forwarded to connected mobile * fail if command that must be executed locally sent to remote host --- src/Simplex/Chat.hs | 4 +++- src/Simplex/Chat/Controller.hs | 15 ++++++++++++++- src/Simplex/Chat/Terminal/Input.hs | 3 ++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5c8e812a2b..a6d5576133 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -400,7 +400,9 @@ execChatCommand rh s = do case parseChatCommand s of Left e -> pure $ chatCmdError u e Right cmd -> case rh of - Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId cmd s + Just rhId + | allowRemoteCommand cmd -> execRemoteCommand u rhId cmd s + | otherwise -> pure $ CRChatCmdError u $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand _ -> execChatCommand_ u cmd execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index dcfa9b431c..3a97d2c32c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -452,8 +452,20 @@ allowRemoteCommand = \case APIStopChat -> False APIActivateChat -> False APISuspendChat _ -> False - SetTempFolder _ -> False QuitChat -> False + SetTempFolder _ -> False + SetFilesFolder _ -> False + SetRemoteHostsFolder _ -> False + APISetXFTPConfig _ -> False + APISetEncryptLocalFiles _ -> False + APIExportArchive _ -> False + APIImportArchive _ -> False + ExportArchive -> False + APIDeleteStorage -> False + APIStorageEncryption _ -> False + APISetNetworkConfig _ -> False + APIGetNetworkConfig -> False + SetLocalDeviceName _ -> False ListRemoteHosts -> False StartRemoteHost _ -> False SwitchRemoteHost {} -> False @@ -1052,6 +1064,7 @@ data RemoteHostError | RHETimeout | RHEBadState -- ^ Illegal state transition | RHEBadVersion {appVersion :: AppVersion} + | RHELocalCommand -- ^ Command not allowed for remote execution | RHEDisconnected {reason :: Text} -- TODO should be sent when disconnected? | RHEProtocolError RemoteProtocolError deriving (Show, Exception) diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 090f06c7b3..ecf1255999 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -56,8 +56,9 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do rh <- readTVarIO $ currentRemoteHost cc let bs = encodeUtf8 $ T.pack s cmd = parseChatCommand bs + rh' = if either (const False) allowRemoteCommand cmd then rh else Nothing unless (isMessage cmd) $ echo s - r <- runReaderT (execChatCommand rh bs) cc + r <- runReaderT (execChatCommand rh' bs) cc processResp s cmd r printRespToTerminal ct cc False rh r startLiveMessage cmd r From e1a80994743da3dd7f1b32c02147b97b7090bdc8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 00:06:45 +0000 Subject: [PATCH 218/294] fix for GHC 8.10.7 --- src/Simplex/Chat.hs | 24 ++++++++++++------------ src/Simplex/Chat/Store/Groups.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 58495727af..68b73fccb0 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3529,19 +3529,19 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ (invitedByGroupMemberId membership) $ \hostId -> do host <- withStore $ \db -> getGroupMember db user groupId hostId forM_ (memberConn host) $ \hostConn -> - void $ sendDirectMessage hostConn (XGrpMemCon m.memberId) (GroupId groupId) + void $ sendDirectMessage hostConn (XGrpMemCon $ memberId (m :: GroupMember)) (GroupId groupId) GCPostMember -> forM_ (invitedByGroupMemberId m) $ \invitingMemberId -> do im <- withStore $ \db -> getGroupMember db user groupId invitingMemberId forM_ (memberConn im) $ \imConn -> - void $ sendDirectMessage imConn (XGrpMemCon m.memberId) (GroupId groupId) + void $ sendDirectMessage imConn (XGrpMemCon $ memberId (m :: GroupMember)) (GroupId groupId) _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn tryChatError (processChatMessage cmdId) >>= \case Right (ACMsg _ chatMsg, withRcpt) -> do ackMsg agentConnId cmdId msgMeta $ if withRcpt then Just "" else Nothing - when (membership.memberRole >= GRAdmin) $ forwardMsg_ chatMsg + when (memberRole (membership :: GroupMember) >= GRAdmin) $ forwardMsg_ chatMsg Left e -> ackMsg agentConnId cmdId msgMeta Nothing >> throwError e where processChatMessage :: Int64 -> m (AChatMessage, Bool) @@ -3619,7 +3619,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- invited members to which this member was introduced invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db user m highlyAvailable let ms = introducedMembers <> invitedMembers - msg = XGrpMsgForward m.memberId chatMsg' brokerTs + msg = XGrpMsgForward (memberId (m :: GroupMember)) chatMsg' brokerTs unless (null ms) $ void $ sendGroupMessage user gInfo ms msg RCVD msgMeta msgRcpt -> @@ -5135,8 +5135,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> ChatMessage 'Json -> UTCTime -> m () - xGrpMsgForward gInfo@GroupInfo {groupId} m memberId msg msgTs = do - when (m.memberRole < GRAdmin) $ throwChatError (CEGroupContactRole m.localDisplayName) + xGrpMsgForward gInfo@GroupInfo {groupId} m@GroupMember {memberRole, localDisplayName} memberId msg msgTs = do + when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole localDisplayName) author <- withStore $ \db -> getGroupMemberByMemberId db user gInfo memberId processForwardedMsg author msg where @@ -5502,7 +5502,7 @@ sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do forwardSupported = do let mcvr = memberChatVRange' m isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward - invitingMemberSupportsForward = case m.invitedByGroupMemberId of + invitingMemberSupportsForward = case invitedByGroupMemberId m of Just invMemberId -> -- can be optimized for large groups by replacing [GroupMember] with Map GroupMemberId GroupMember case find (\m' -> groupMemberId' m' == invMemberId) members of @@ -5547,13 +5547,13 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta let agentMsgId = fst $ recipient agentMsgMeta newMsg = NewMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} - amId = Just am'.groupMemberId + amId = Just $ groupMemberId' am' msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery amId) `catchChatError` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId forM_ (memberConn fm) $ \fmConn -> - void $ sendDirectMessage fmConn (XGrpMemCon am'.memberId) (GroupId groupId) + void $ sendDirectMessage fmConn (XGrpMemCon $ memberId (am' :: GroupMember)) (GroupId groupId) throwError e _ -> throwError e pure (am', conn', msg) @@ -5567,9 +5567,9 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember msgBody ChatMes `catchChatError` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ (Just authorGroupMemberId) Nothing) -> do am <- withStore $ \db -> getGroupMember db user groupId authorGroupMemberId - if sameMemberId refAuthorMember.memberId am + if sameMemberId (memberId (refAuthorMember :: GroupMember)) am then forM_ (memberConn forwardingMember) $ \fmConn -> - void $ sendDirectMessage fmConn (XGrpMemCon am.memberId) (GroupId groupId) + void $ sendDirectMessage fmConn (XGrpMemCon $ memberId (am :: GroupMember)) (GroupId groupId) else toView $ CRMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" throwError e _ -> throwError e @@ -5599,7 +5599,7 @@ saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content ciFile itemTimed live (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure (ciId, quotedItem) - liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs msg.forwardedByGroupMemberId createdAt + liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs (forwardedByGroupMemberId (msg :: RcvMessage)) createdAt mkChatItem :: forall c d. MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> IO (ChatItem c d) mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs forwardedByGroupMemberId currentTs = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 6361f7a6f7..d71ea80c82 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -354,7 +354,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?,?)" (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs) insertedRowId db - let JVersionRange hostVRange = hostConn.peerChatVRange + let JVersionRange hostVRange = peerChatVRange hostConn GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs supportedChatVRange let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7918d68ffc..03c8d86c7a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -488,7 +488,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} withItemDeleted item = case chatItemDeletedText ci (chatInfoMembership chat) of Nothing -> item Just t -> item <> styled (colored Red) (" [" <> t <> "]") - withGroupMsgForwarded item = case meta.forwardedByGroupMemberId of + withGroupMsgForwarded item = case forwardedByGroupMemberId (meta :: CIMeta c d) of Nothing -> item Just _ -> item <> styled (colored Yellow) (" [>>]" :: String) withSndFile = withFile viewSentFileInvitation From 85e44dcb77192c5f518aeafa05d2d3aeb451be30 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 13:05:59 +0400 Subject: [PATCH 219/294] core: split group message forwarding tests (#3400) --- tests/ChatTests/Groups.hs | 124 ++++++++++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 24 deletions(-) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index edf3d2fab8..9278d4071c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -105,8 +105,14 @@ chatGroupTests = do it "invited member replaces member contact reference if it already exists" testMemberContactInvitedConnectionReplaced it "share incognito profile" testMemberContactIncognito it "sends and updates profile when creating contact" testMemberContactProfileUpdate - describe "forwarding messages" $ do - it "admin should forward messages between invitee and introduced" testGroupMsgForward + describe "group message forwarding" $ do + it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward + it "forward message edit (x.msg.update)" testGroupMsgForwardEdit + it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction + it "forward message deletion (x.msg.del)" testGroupMsgForwardDeletion + it "forward file (x.msg.file.descr)" testGroupMsgForwardFile + it "forward role change (x.grp.mem.role)" testGroupMsgForwardChangeRole + it "forward new member announcement (x.grp.mem.new)" testGroupMsgForwardNewMember where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -3902,18 +3908,9 @@ testMemberContactProfileUpdate = testGroupMsgForward :: HasCallStack => FilePath -> IO () testGroupMsgForward = - testChatCfg4 cfg aliceProfile bobProfile cathProfile danProfile $ - \alice bob cath dan -> withXFTPServer $ do - createGroup3 "team" alice bob cath - - threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected - - void $ withCCTransaction bob $ \db -> - DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" - void $ withCCTransaction cath $ \db -> - DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" - void $ withCCTransaction alice $ \db -> - DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath bob #> "#team hi there" alice <# "#team bob> hi there" @@ -3937,22 +3934,80 @@ testGroupMsgForward = cath <# "#team bob> hi there [>>]" cath <# "#team hey team" +setupGroupForwarding3 :: String -> TestCC -> TestCC -> TestCC -> IO () +setupGroupForwarding3 gName alice bob cath = do + createGroup3 gName alice bob cath + + threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + + void $ withCCTransaction bob $ \db -> + DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" + void $ withCCTransaction cath $ \db -> + DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + +testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO () +testGroupMsgForwardEdit = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + bob ##> "! #team hello there" bob <# "#team [edited] hello there" alice <# "#team bob> [edited] hello there" cath <# "#team bob> [edited] hello there" -- TODO show as forwarded - cath ##> "+1 #team hello there" + alice ##> "/tail #team 1" + alice <# "#team bob> hello there" + + bob ##> "/tail #team 1" + bob <# "#team hello there" + + cath ##> "/tail #team 1" + cath <# "#team bob> hello there [>>]" + +testGroupMsgForwardReaction :: HasCallStack => FilePath -> IO () +testGroupMsgForwardReaction = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + + cath ##> "+1 #team hi there" cath <## "added 👍" - alice <# "#team cath> > bob hello there" + alice <# "#team cath> > bob hi there" alice <## " + 👍" - bob <# "#team cath> > bob hello there" + bob <# "#team cath> > bob hi there" bob <## " + 👍" - bob ##> "\\ #team hello there" +testGroupMsgForwardDeletion :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeletion = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + + bob ##> "\\ #team hi there" bob <## "message marked deleted" - alice <# "#team bob> [marked deleted] hello there" - cath <# "#team bob> [marked deleted] hello there" -- TODO show as forwarded + alice <# "#team bob> [marked deleted] hi there" + cath <# "#team bob> [marked deleted] hi there" -- TODO show as forwarded + +testGroupMsgForwardFile :: HasCallStack => FilePath -> IO () +testGroupMsgForwardFile = + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + \alice bob cath -> withXFTPServer $ do + setupGroupForwarding3 "team" alice bob cath bob #> "/f #team ./tests/fixtures/test.jpg" bob <## "use /fc 1 to cancel sending" @@ -3972,12 +4027,26 @@ testGroupMsgForward = src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + +testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO () +testGroupMsgForwardChangeRole = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + setupGroupForwarding3 "team" alice bob cath cath ##> "/mr #team bob member" cath <## "#team: you changed the role of bob from admin to member" alice <## "#team: cath changed the role of bob from admin to member" bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded +testGroupMsgForwardNewMember :: HasCallStack => FilePath -> IO () +testGroupMsgForwardNewMember = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + setupGroupForwarding3 "team" alice bob cath + connectUsers cath dan cath ##> "/a #team dan" cath <## "invitation to join the group #team sent to dan" @@ -3994,14 +4063,21 @@ testGroupMsgForward = bob <## "#team: cath added dan (Daniel) to the group (connecting...)", -- TODO show as forwarded dan <## "#team: member alice (Alice) is connected" ] + dan #> "#team hello all" alice <# "#team dan> hello all" -- bob <# "#team dan> hello all [>>]" cath <# "#team dan> hello all" - + bob #> "#team hi all" alice <# "#team bob> hi all" cath <# "#team bob> hi all [>>]" - -- dan <# "#team bob> hi all" - where - cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"} + -- dan <# "#team bob> hi all [>>]" + + bob ##> "/ms team" + bob + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): admin, you, connected", + "cath (Catherine): admin, connected", + "dan (Daniel): member" + ] From ba94f76a9006020d661f4938c956d841bbb2be7a Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 20 Nov 2023 11:33:43 +0200 Subject: [PATCH 220/294] core: fix remote session stuck in Starting after crashed rcConnect (#3399) --- src/Simplex/Chat/Remote.hs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index f1ff0cada7..1af49a2da0 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -133,7 +133,7 @@ startRemoteHost rh_ = do Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing withRemoteHostSession_ rhKey $ maybe (Right ((), Just RHSessionStarting)) (\_ -> Left $ ChatErrorRemoteHost rhKey RHEBusy) ctrlAppInfo <- mkCtrlAppInfo - (invitation, rchClient, vars) <- withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast + (invitation, rchClient, vars) <- handleConnectError rhKey . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast cmdOk <- newEmptyTMVarIO rhsWaitSession <- async $ do rhKeyVar <- newTVarIO rhKey @@ -157,6 +157,11 @@ startRemoteHost rh_ = do unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ RHEBadVersion appVersion when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding pure hostInfo + handleConnectError :: ChatMonad m => RHKey -> m a -> m a + handleConnectError rhKey action = action `catchChatError` \err -> do + logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err + cancelRemoteHostSession True rhKey + throwError err handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () handleHostError rhKeyVar action = action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err From 3a510eeaf0795fcc8ac5c6df4cd70f66b7aa611a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 14:00:55 +0400 Subject: [PATCH 221/294] core: rename forwarded fields (#3401) --- src/Simplex/Chat.hs | 8 ++++---- src/Simplex/Chat/Messages.hs | 10 +++++----- src/Simplex/Chat/Store/Messages.hs | 26 +++++++++++++------------- src/Simplex/Chat/View.hs | 4 ++-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 524fae2bd8..91ae259445 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -5599,20 +5599,20 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False saveRcvChatItem' :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> m (ChatItem c 'MDRcv) -saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content ciFile itemTimed live = do +saveRcvChatItem' user cd msg@RcvMessage {forwardedByMember} sharedMsgId_ brokerTs content ciFile itemTimed live = do createdAt <- liftIO getCurrentTime (ciId, quotedItem) <- withStore' $ \db -> do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure (ciId, quotedItem) - liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs msg.forwardedByGroupMemberId createdAt + liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs forwardedByMember createdAt mkChatItem :: forall c d. MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> IO (ChatItem c d) -mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs forwardedByGroupMemberId currentTs = do +mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs forwardedByMember currentTs = do let itemText = ciContentToText content itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByGroupMemberId currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByMember currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} deleteDirectCI :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> m ChatResponse diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index d35ed88185..0cb7f5e82b 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -341,18 +341,18 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, editable :: Bool, - forwardedByGroupMemberId :: Maybe GroupMemberId, + forwardedByMember :: Maybe GroupMemberId, createdAt :: UTCTime, updatedAt :: UTCTime } deriving (Show, Generic) mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByGroupMemberId createdAt updatedAt = +mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = let editable = case itemContent of CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay && isNothing itemDeleted _ -> False - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByGroupMemberId, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByMember, createdAt, updatedAt} instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions @@ -816,8 +816,8 @@ data RcvMessage = RcvMessage chatMsgEvent :: AChatMsgEvent, sharedMsgId_ :: Maybe SharedMsgId, msgBody :: MsgBody, - authorGroupMemberId :: Maybe GroupMemberId, - forwardedByGroupMemberId :: Maybe GroupMemberId + authorMember :: Maybe GroupMemberId, + forwardedByMember :: Maybe GroupMemberId } data PendingGroupMessage = PendingGroupMessage diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index d98023ad83..343d33484e 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -200,7 +200,7 @@ createNewMessageAndRcvMsgDelivery db connOrGroupId newMessage sharedMsgId_ RcvMs pure msg createNewRcvMessage :: forall e. (MsgEncodingI e) => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage -createNewRcvMessage db connOrGroupId NewMessage{chatMsgEvent, msgBody} sharedMsgId_ authorGroupMemberId forwardedByGroupMemberId = +createNewRcvMessage db connOrGroupId NewMessage{chatMsgEvent, msgBody} sharedMsgId_ authorMember forwardedByMember = case connOrGroupId of ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing GroupId groupId -> case sharedMsgId_ of @@ -230,9 +230,9 @@ createNewRcvMessage db connOrGroupId NewMessage{chatMsgEvent, msgBody} sharedMsg (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorGroupMemberId, forwardedByGroupMemberId) + (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) msgId <- insertedRowId db - pure RcvMessage{msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorGroupMemberId, forwardedByGroupMemberId} + pure RcvMessage{msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember} createSndMsgDeliveryEvent :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> ExceptT StoreError IO () createSndMsgDeliveryEvent db connId agentMsgId sndMsgDeliveryStatus = do @@ -366,8 +366,8 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv Nothing -> (Just False, Nothing) createNewRcvChatItem :: DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c)) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByGroupMemberId} sharedMsgId_ ciContent timed live itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow timed live itemTs forwardedByGroupMemberId createdAt +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live itemTs createdAt = do + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow timed live itemTs forwardedByMember createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem) where @@ -389,7 +389,7 @@ createNewChatItemNoMsg db user chatDirection ciContent itemTs = quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CITimed -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs forwardedByGroupMemberId createdAt = do +createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs forwardedByMember createdAt = do DB.execute db [sql| @@ -408,7 +408,7 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q pure ciId where itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByGroupMemberId) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing) @@ -594,7 +594,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, - -- CIMeta forwardedByGroupMemberId + -- CIMeta forwardedByMember i.forwarded_by_group_member_id, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, @@ -1074,7 +1074,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction -- this function can be changed so it never fails, not only avoid failure on invalid json toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -1110,13 +1110,13 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, DBCIBlocked -> Just (CIBlocked @'CTGroup deletedTs) _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByGroupMemberId createdAt updatedAt + in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} toGroupChatItemList :: UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = - either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByGroupMemberId :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) +toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = + either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) toGroupChatItemList _ _ _ = [] getAllChatItems :: DB.Connection -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] @@ -1560,7 +1560,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, - -- CIMeta forwardedByGroupMemberId + -- CIMeta forwardedByMember i.forwarded_by_group_member_id, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 982c5208af..85511e09eb 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -449,7 +449,7 @@ viewChats ts tz = concatMap chatPreview . reverse _ -> [] viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} doShow ts tz = +viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {forwardedByMember}, content, quotedItem, file} doShow ts tz = withGroupMsgForwarded . withItemDeleted <$> (case chat of DirectChat c -> case chatDir of CIDirectSnd -> case content of @@ -489,7 +489,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} withItemDeleted item = case chatItemDeletedText ci (chatInfoMembership chat) of Nothing -> item Just t -> item <> styled (colored Red) (" [" <> t <> "]") - withGroupMsgForwarded item = case meta.forwardedByGroupMemberId of + withGroupMsgForwarded item = case forwardedByMember of Nothing -> item Just _ -> item <> styled (colored Yellow) (" [>>]" :: String) withSndFile = withFile viewSentFileInvitation From 68cbc605be280fc51bd9c8db9c0d8ea8cfdb6d61 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 20 Nov 2023 12:19:00 +0200 Subject: [PATCH 222/294] add remote session sequence to prevent stale state updates (#3390) * add remote session sequence to prevent stale state updates * remote RHStateKey * add StateSeq check to controller * clean up * simplify * undo withRemoteXSession API change * simplify --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 6 +- src/Simplex/Chat/Controller.hs | 7 +- src/Simplex/Chat/Remote.hs | 296 ++++++++++++++++++------------- src/Simplex/Chat/Remote/Types.hs | 2 + tests/RemoteTests.hs | 4 +- 5 files changed, 182 insertions(+), 133 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a6d5576133..049f406809 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -215,6 +215,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen currentCalls <- atomically TM.empty localDeviceName <- newTVarIO "" -- TODO set in config multicastSubscribers <- newTMVarIO 0 + remoteSessionSeq <- newTVarIO 0 remoteHostSessions <- atomically TM.empty remoteHostsFolder <- newTVarIO Nothing remoteCtrlSession <- newTVarIO Nothing @@ -250,6 +251,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen currentCalls, localDeviceName, multicastSubscribers, + remoteSessionSeq, remoteHostSessions, remoteHostsFolder, remoteCtrlSession, @@ -377,8 +379,8 @@ restoreCalls = do stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do - readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost False) - atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl False) + readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost False . snd) + atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl False . snd) disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3a97d2c32c..2ccc2ca12e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -184,9 +184,10 @@ data ChatController = ChatController currentCalls :: TMap ContactId Call, localDeviceName :: TVar Text, multicastSubscribers :: TMVar Int, - remoteHostSessions :: TMap RHKey RemoteHostSession, -- All the active remote hosts + remoteSessionSeq :: TVar Int, + remoteHostSessions :: TMap RHKey (SessionSeq, RemoteHostSession), -- All the active remote hosts remoteHostsFolder :: TVar (Maybe FilePath), -- folder for remote hosts data - remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers + remoteCtrlSession :: TVar (Maybe (SessionSeq, RemoteCtrlSession)), -- Supervisor process for hosted controllers config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, expireCIThreads :: TMap UserId (Maybe (Async ())), @@ -1196,7 +1197,7 @@ toView event = do session <- asks remoteCtrlSession atomically $ readTVar session >>= \case - Just RCSessionConnected {remoteOutputQ} | allowRemoteEvent event -> + Just (_, RCSessionConnected {remoteOutputQ}) | allowRemoteEvent event -> writeTBQueue remoteOutputQ event -- TODO potentially, it should hold some events while connecting _ -> writeTBQueue localQ (Nothing, Nothing, event) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 1af49a2da0..d9ef5bd648 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -21,7 +21,6 @@ import Control.Monad.Reader import Crypto.Random (getRandomBytes) import qualified Data.Aeson as J import qualified Data.Aeson.Types as JT -import Data.Bifunctor (second) import Data.ByteString (ByteString) import qualified Data.ByteString.Base64.URL as B64U import Data.ByteString.Builder (Builder) @@ -29,7 +28,7 @@ import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) import Data.List.NonEmpty (nonEmpty) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -96,33 +95,43 @@ discoveryTimeout = 60000000 -- * Desktop side getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient -getRemoteHostClient rhId = withRemoteHostSession rhKey $ \case - s@RHSessionConnected {rhClient} -> Right (rhClient, s) - _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState +getRemoteHostClient rhId = do + sessions <- asks remoteHostSessions + liftIOEither . atomically $ TM.lookup rhKey sessions >>= \case + Just (_, RHSessionConnected {rhClient}) -> pure $ Right rhClient + Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState + Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing where rhKey = RHId rhId -withRemoteHostSession :: ChatMonad m => RHKey -> (RemoteHostSession -> Either ChatError (a, RemoteHostSession)) -> m a -withRemoteHostSession rhKey state = withRemoteHostSession_ rhKey $ maybe (Left $ ChatErrorRemoteHost rhKey $ RHEMissing) ((second . second) Just . state) - -withRemoteHostSession_ :: ChatMonad m => RHKey -> (Maybe RemoteHostSession -> Either ChatError (a, Maybe RemoteHostSession)) -> m a -withRemoteHostSession_ rhKey state = do +withRemoteHostSession :: ChatMonad m => RHKey -> SessionSeq -> (RemoteHostSession -> Either ChatError (a, RemoteHostSession)) -> m a +withRemoteHostSession rhKey sseq f = do sessions <- asks remoteHostSessions - r <- atomically $ do - s <- TM.lookup rhKey sessions - case state s of - Left e -> pure $ Left e - Right (a, s') -> Right a <$ maybe (TM.delete rhKey) (TM.insert rhKey) s' sessions + r <- atomically $ + TM.lookup rhKey sessions >>= \case + Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing + Just (stateSeq, state) + | stateSeq /= sseq -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState + | otherwise -> case f state of + Right (r, newState) -> Right r <$ TM.insert rhKey (sseq, newState) sessions + Left ce -> pure $ Left ce liftEither r -setNewRemoteHostId :: ChatMonad m => RHKey -> RemoteHostId -> m () -setNewRemoteHostId rhKey rhId = do +-- | Transition session state with a 'RHNew' ID to an assigned 'RemoteHostId' +setNewRemoteHostId :: ChatMonad m => SessionSeq -> RemoteHostId -> m () +setNewRemoteHostId sseq rhId = do sessions <- asks remoteHostSessions - r <- atomically $ do - TM.lookupDelete rhKey sessions >>= \case - Nothing -> pure $ Left $ ChatErrorRemoteHost rhKey RHEMissing - Just s -> Right () <$ TM.insert (RHId rhId) s sessions - liftEither r + liftIOEither . atomically $ do + TM.lookup RHNew sessions >>= \case + Nothing -> err RHEMissing + Just sess@(stateSeq, _) + | stateSeq /= sseq -> err RHEBadState + | otherwise -> do + TM.delete RHNew sessions + TM.insert (RHId rhId) sess sessions + pure $ Right () + where + err = pure . Left . ChatErrorRemoteHost RHNew startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) startRemoteHost rh_ = do @@ -131,16 +140,16 @@ startRemoteHost rh_ = do rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId pure (RHId rhId, multicast, Just $ remoteHostInfo rh $ Just RHSStarting, hostPairing) -- get from the database, start multicast if requested Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing - withRemoteHostSession_ rhKey $ maybe (Right ((), Just RHSessionStarting)) (\_ -> Left $ ChatErrorRemoteHost rhKey RHEBusy) + sseq <- startRemoteHostSession rhKey ctrlAppInfo <- mkCtrlAppInfo - (invitation, rchClient, vars) <- handleConnectError rhKey . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast + (invitation, rchClient, vars) <- handleConnectError rhKey sseq . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast cmdOk <- newEmptyTMVarIO rhsWaitSession <- async $ do rhKeyVar <- newTVarIO rhKey atomically $ takeTMVar cmdOk - handleHostError rhKeyVar $ waitForHostSession remoteHost_ rhKey rhKeyVar vars + handleHostError sseq rhKeyVar $ waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} - withRemoteHostSession rhKey $ \case + withRemoteHostSession rhKey sseq $ \case RHSessionStarting -> let inv = decodeLatin1 $ strEncode invitation in Right ((), RHSessionConnecting inv rhs) @@ -157,85 +166,103 @@ startRemoteHost rh_ = do unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ RHEBadVersion appVersion when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding pure hostInfo - handleConnectError :: ChatMonad m => RHKey -> m a -> m a - handleConnectError rhKey action = action `catchChatError` \err -> do + handleConnectError :: ChatMonad m => RHKey -> SessionSeq -> m a -> m a + handleConnectError rhKey sessSeq action = action `catchChatError` \err -> do logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err - cancelRemoteHostSession True rhKey + cancelRemoteHostSession (Just sessSeq) rhKey throwError err - handleHostError :: ChatMonad m => TVar RHKey -> m () -> m () - handleHostError rhKeyVar action = action `catchChatError` \err -> do + handleHostError :: ChatMonad m => SessionSeq -> TVar RHKey -> m () -> m () + handleHostError sessSeq rhKeyVar action = action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err - readTVarIO rhKeyVar >>= cancelRemoteHostSession True - waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () - waitForHostSession remoteHost_ rhKey rhKeyVar vars = do + readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just sessSeq) + waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars = do (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars let sessionCode = verificationCode sessId - withRemoteHostSession rhKey $ \case - RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') -- TODO check it's the same session? + withRemoteHostSession rhKey sseq $ \case + RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState - -- display confirmation code, wait for mobile to confirm - let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just $ RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ + let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} (RCHostSession {sessionKeys}, rhHello, pairing') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars' hostInfo@HostAppInfo {deviceName = hostDeviceName} <- liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello - withRemoteHostSession rhKey $ \case - RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') -- TODO check it's the same session? + withRemoteHostSession rhKey sseq $ \case + RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState - -- update remoteHost with updated pairing - rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName RHSConfirmed {sessionCode} + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName sseq RHSConfirmed {sessionCode} let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' toView $ CRNewRemoteHost rhi - disconnected <- toIO $ onDisconnected rhKey' - httpClient <- liftEitherError (httpError rhKey') $ attachRevHTTP2Client disconnected tls + -- set up HTTP transport and remote profile protocol + disconnected <- toIO $ onDisconnected rhKey' sseq + httpClient <- liftEitherError (httpError remoteHostId) $ attachRevHTTP2Client disconnected tls rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo pollAction <- async $ pollEvents remoteHostId rhClient - withRemoteHostSession rhKey' $ \case + withRemoteHostSession rhKey' sseq $ \case RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) - _ -> Left $ ChatErrorRemoteHost rhKey' RHEBadState + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} - upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> RemoteHostSessionState -> m RemoteHostInfo - upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName state = do + upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo + upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName sseq state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ case rhi_ of Nothing -> do storePath <- liftIO randomStorePath rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath pairing' >>= getRemoteHost db - setNewRemoteHostId RHNew remoteHostId + setNewRemoteHostId sseq remoteHostId pure $ remoteHostInfo rh $ Just state Just rhi@RemoteHostInfo {remoteHostId} -> do withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' pure (rhi :: RemoteHostInfo) {sessionState = Just state} - onDisconnected :: ChatMonad m => RHKey -> m () - onDisconnected rhKey = do - logDebug "HTTP2 client disconnected" - cancelRemoteHostSession True rhKey + onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m () + onDisconnected rhKey sseq = do + logDebug $ "HTTP2 client disconnected: " <> tshow (rhKey, sseq) + cancelRemoteHostSession (Just sseq) rhKey pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () pollEvents rhId rhClient = do oq <- asks outputQ forever $ do r_ <- liftRH rhId $ remoteRecv rhClient 10000000 forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) - httpError :: RHKey -> HTTP2ClientError -> ChatError - httpError rhKey = ChatErrorRemoteHost rhKey . RHEProtocolError . RPEHTTP2 . tshow + httpError :: RemoteHostId -> HTTP2ClientError -> ChatError + httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow + +startRemoteHostSession :: ChatMonad m => RHKey -> m SessionSeq +startRemoteHostSession rhKey = do + sessions <- asks remoteHostSessions + nextSessionSeq <- asks remoteSessionSeq + liftIOEither . atomically $ + TM.lookup rhKey sessions >>= \case + Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBusy + Nothing -> do + sessionSeq <- stateTVar nextSessionSeq $ \s -> (s, s + 1) + Right sessionSeq <$ TM.insert rhKey (sessionSeq, RHSessionStarting) sessions closeRemoteHost :: ChatMonad m => RHKey -> m () closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey - cancelRemoteHostSession False rhKey + cancelRemoteHostSession Nothing rhKey -cancelRemoteHostSession :: ChatMonad m => Bool -> RHKey -> m () -cancelRemoteHostSession handlingError rhKey = handleAny (logError . tshow) $ do - chatModifyVar currentRemoteHost $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH +cancelRemoteHostSession :: ChatMonad m => Maybe SessionSeq -> RHKey -> m () +cancelRemoteHostSession sseq_ rhKey = do sessions <- asks remoteHostSessions - session_ <- atomically $ TM.lookupDelete rhKey sessions -- XXX: when invoked from delayed error handler this can wipe the next session instead - forM_ session_ $ \session -> do + crh <- asks currentRemoteHost + deregistered <- atomically $ + TM.lookup rhKey sessions >>= \case + Nothing -> pure Nothing + Just (sessSeq, _) | maybe False (/= sessSeq) sseq_ -> pure Nothing -- ignore cancel from a ghost session handler + Just (_, rhs) -> do + TM.delete rhKey sessions + modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH + pure $ Just rhs + forM_ deregistered $ \session -> do liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) when handlingError $ toView $ CRRemoteHostStopped rhId_ where + handlingError = isJust sseq_ rhId_ = case rhKey of RHNew -> Nothing RHId rhId -> Just rhId @@ -270,7 +297,7 @@ listRemoteHosts = do map (rhInfo sessions) <$> withStore' getRemoteHosts where rhInfo sessions rh@RemoteHost {remoteHostId} = - remoteHostInfo rh (rhsSessionState <$> M.lookup (RHId remoteHostId) sessions) + remoteHostInfo rh $ rhsSessionState . snd <$> M.lookup (RHId remoteHostId) sessions switchRemoteHost :: ChatMonad m => Maybe RemoteHostId -> m (Maybe RemoteHostInfo) switchRemoteHost rhId_ = do @@ -279,7 +306,7 @@ switchRemoteHost rhId_ = do rh <- withStore (`getRemoteHost` rhId) sessions <- chatReadVar remoteHostSessions case M.lookup rhKey sessions of - Just RHSessionConnected {tls} -> pure $ remoteHostInfo rh $ Just RHSConnected {sessionCode = tlsSessionCode tls} + Just (_, RHSessionConnected {tls}) -> pure $ remoteHostInfo rh $ Just RHSConnected {sessionCode = tlsSessionCode tls} _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive rhi_ <$ chatWriteVar currentRemoteHost rhId_ @@ -352,23 +379,23 @@ liftRH rhId = liftError (ChatErrorRemoteHost (RHId rhId) . RHEProtocolError) -- | Use provided OOB link as an annouce connectRemoteCtrlURI :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) -connectRemoteCtrlURI signedInv = handleCtrlError "connectRemoteCtrl" $ do +connectRemoteCtrlURI signedInv = do verifiedInv <- maybe (throwError $ ChatErrorRemoteCtrl RCEBadInvitation) pure $ verifySignedInvitation signedInv - withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) - connectRemoteCtrl verifiedInv + sseq <- startRemoteCtrlSession + connectRemoteCtrl verifiedInv sseq -- ** Multicast findKnownRemoteCtrl :: ChatMonad m => m () -findKnownRemoteCtrl = handleCtrlError "findKnownRemoteCtrl" $ do +findKnownRemoteCtrl = do knownCtrls <- withStore' getRemoteCtrls pairings <- case nonEmpty knownCtrls of Nothing -> throwError $ ChatErrorRemoteCtrl RCENoKnownControllers Just ne -> pure $ fmap (\RemoteCtrl {ctrlPairing} -> ctrlPairing) ne - withRemoteCtrlSession_ $ maybe (Right ((), Just RCSessionStarting)) (\_ -> Left $ ChatErrorRemoteCtrl RCEBusy) + sseq <- startRemoteCtrlSession foundCtrl <- newEmptyTMVarIO cmdOk <- newEmptyTMVarIO - action <- async $ handleCtrlError "findKnownRemoteCtrl.discover" $ do + action <- async $ handleCtrlError sseq "findKnownRemoteCtrl.discover" $ do atomically $ takeTMVar cmdOk (RCCtrlPairing {ctrlFingerprint}, inv) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case @@ -376,27 +403,42 @@ findKnownRemoteCtrl = handleCtrlError "findKnownRemoteCtrl" $ do Just rc -> pure rc atomically $ putTMVar foundCtrl (rc, inv) toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching)} - withRemoteCtrlSession $ \case - RCSessionStarting -> Right ((), RCSessionSearching {action, foundCtrl}) + updateRemoteCtrlSession sseq $ \case + RCSessionStarting -> Right RCSessionSearching {action, foundCtrl} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m (RemoteCtrlInfo, CtrlAppInfo) confirmRemoteCtrl rcId = do - (listener, found) <- withRemoteCtrlSession $ \case - RCSessionSearching {action, foundCtrl} -> Right ((action, foundCtrl), RCSessionStarting) -- drop intermediate "Searching" state so connectRemoteCtrl can proceed - _ -> throwError $ ChatErrorRemoteCtrl RCEBadState + session <- asks remoteCtrlSession + (sseq, listener, found) <- liftIOEither $ atomically $ do + readTVar session >>= \case + Just (sseq, RCSessionSearching {action, foundCtrl}) -> do + writeTVar session $ Just (sseq, RCSessionStarting) -- drop intermediate "Searching" state so connectRemoteCtrl can proceed + pure $ Right (sseq, action, foundCtrl) + _ -> pure . Left $ ChatErrorRemoteCtrl RCEBadState uninterruptibleCancel listener (RemoteCtrl{remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found unless (rcId == foundRcId) $ throwError $ ChatErrorRemoteCtrl RCEBadController - connectRemoteCtrl verifiedInv >>= \case + connectRemoteCtrl verifiedInv sseq >>= \case (Nothing, _) -> throwChatError $ CEInternalError "connecting with a stored ctrl" (Just rci, appInfo) -> pure (rci, appInfo) -- ** Common -connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) -connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) = handleCtrlError "connectRemoteCtrl" $ do +startRemoteCtrlSession :: ChatMonad m => m SessionSeq +startRemoteCtrlSession = do + session <- asks remoteCtrlSession + nextSessionSeq <- asks remoteSessionSeq + liftIOEither . atomically $ + readTVar session >>= \case + Just _ -> pure . Left $ ChatErrorRemoteCtrl RCEBusy + Nothing -> do + sseq <- stateTVar nextSessionSeq $ \s -> (s, s + 1) + Right sseq <$ writeTVar session (Just (sseq, RCSessionStarting)) + +connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> SessionSeq -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) +connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) sseq = handleCtrlError sseq "connectRemoteCtrl" $ do (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca mapM_ (validateRemoteCtrl inv) rc_ @@ -406,8 +448,8 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk - handleCtrlError "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars - updateRemoteCtrlSession $ \case + handleCtrlError sseq "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars + updateRemoteCtrlSession sseq $ \case RCSessionStarting -> Right RCSessionConnecting {remoteCtrlId_ = remoteCtrlId' <$> rc_, rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState atomically $ putTMVar cmdOk () @@ -419,7 +461,7 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) waitForCtrlSession rc_ ctrlName rcsClient vars = do (uniq, tls, rcsWaitConfirmation) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars let sessionCode = verificationCode uniq - updateRemoteCtrlSession $ \case + updateRemoteCtrlSession sseq $ \case RCSessionConnecting {rcsWaitSession} -> let remoteCtrlId_ = remoteCtrlId' <$> rc_ in Right RCSessionPendingConfirmation {remoteCtrlId_, ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} @@ -529,7 +571,7 @@ handleGetFile encryption User {userId} RemoteFile {userId = commandUserId, fileI listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] listRemoteCtrls = do - session <- chatReadVar remoteCtrlSession + session <- snd <$$> chatReadVar remoteCtrlSession let rcId = sessionRcId =<< session sessState = rcsSessionState <$> session map (rcInfo rcId sessState) <$> withStore' getRemoteCtrls @@ -549,24 +591,26 @@ remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionState = -- | Take a look at emoji of tlsunique, commit pairing, and start session server verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteCtrlInfo -verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemoteCtrlSession" $ do - (client, ctrlName, sessionCode, vars) <- - getRemoteCtrlSession >>= \case - RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation} -> pure (rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) +verifyRemoteCtrlSession execChatCommand sessCode' = do + (sseq, client, ctrlName, sessionCode, vars) <- + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just (sseq, RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation}) -> pure (sseq, rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) _ -> throwError $ ChatErrorRemoteCtrl RCEBadState - let verified = sameVerificationCode sessCode' sessionCode - timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . liftIO $ confirmCtrlSession client verified -- signal verification result before crashing - unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode - (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars - rc@RemoteCtrl {remoteCtrlId} <- upsertRemoteCtrl ctrlName rcCtrlPairing - remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO - encryption <- mkCtrlRemoteCrypto sessionKeys $ tlsUniq tls - http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand encryption remoteOutputQ - void . forkIO $ monitor http2Server - withRemoteCtrlSession $ \case - RCSessionPendingConfirmation {} -> Right ((), RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, tls, http2Server, remoteOutputQ}) - _ -> Left $ ChatErrorRemoteCtrl RCEBadState - pure $ remoteCtrlInfo rc $ Just RCSConnected {sessionCode = tlsSessionCode tls} + handleCtrlError sseq "verifyRemoteCtrlSession" $ do + let verified = sameVerificationCode sessCode' sessionCode + timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . liftIO $ confirmCtrlSession client verified -- signal verification result before crashing + unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode + (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars + rc@RemoteCtrl {remoteCtrlId} <- upsertRemoteCtrl ctrlName rcCtrlPairing + remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO + encryption <- mkCtrlRemoteCrypto sessionKeys $ tlsUniq tls + http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand encryption remoteOutputQ + void . forkIO $ monitor sseq http2Server + updateRemoteCtrlSession sseq $ \case + RCSessionPendingConfirmation {} -> Right RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, tls, http2Server, remoteOutputQ} + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + pure $ remoteCtrlInfo rc $ Just RCSConnected {sessionCode = tlsSessionCode tls} where upsertRemoteCtrl :: ChatMonad m => Text -> RCCtrlPairing -> m RemoteCtrl upsertRemoteCtrl ctrlName rcCtrlPairing = withStore $ \db -> do @@ -577,28 +621,35 @@ verifyRemoteCtrlSession execChatCommand sessCode' = handleCtrlError "verifyRemot let dhPrivKey' = dhPrivKey rcCtrlPairing liftIO $ updateRemoteCtrl db rc ctrlName dhPrivKey' pure rc {ctrlDeviceName = ctrlName, ctrlPairing = ctrlPairing {dhPrivKey = dhPrivKey'}} - monitor :: ChatMonad m => Async () -> m () - monitor server = do + monitor :: ChatMonad m => SessionSeq -> Async () -> m () + monitor sseq server = do res <- waitCatch server logInfo $ "HTTP2 server stopped: " <> tshow res - cancelActiveRemoteCtrl True + cancelActiveRemoteCtrl (Just sseq) stopRemoteCtrl :: ChatMonad m => m () -stopRemoteCtrl = cancelActiveRemoteCtrl False +stopRemoteCtrl = cancelActiveRemoteCtrl Nothing -handleCtrlError :: ChatMonad m => Text -> m a -> m a -handleCtrlError name action = +handleCtrlError :: ChatMonad m => SessionSeq -> Text -> m a -> m a +handleCtrlError sseq name action = action `catchChatError` \e -> do logError $ name <> " remote ctrl error: " <> tshow e - cancelActiveRemoteCtrl True + cancelActiveRemoteCtrl (Just sseq) throwError e -cancelActiveRemoteCtrl :: ChatMonad m => Bool -> m () -cancelActiveRemoteCtrl handlingError = handleAny (logError . tshow) $ do - session_ <- withRemoteCtrlSession_ (\s -> pure (s, Nothing)) +-- | Stop session controller, unless session update key is present but stale +cancelActiveRemoteCtrl :: ChatMonad m => Maybe SessionSeq -> m () +cancelActiveRemoteCtrl sseq_ = handleAny (logError . tshow) $ do + var <- asks remoteCtrlSession + session_ <- atomically $ readTVar var >>= \case + Nothing -> pure Nothing + Just (oldSeq, _) | maybe False (/= oldSeq) sseq_ -> pure Nothing + Just (_, s) -> Just s <$ writeTVar var Nothing forM_ session_ $ \session -> do liftIO $ cancelRemoteCtrl handlingError session when handlingError $ toView CRRemoteCtrlStopped + where + handlingError = isJust sseq_ cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () cancelRemoteCtrl handlingError = \case @@ -622,31 +673,24 @@ deleteRemoteCtrl rcId = do -- TODO check it exists withStore' (`deleteRemoteCtrlRecord` rcId) -getRemoteCtrlSession :: ChatMonad m => m RemoteCtrlSession -getRemoteCtrlSession = - chatReadVar remoteCtrlSession >>= maybe (throwError $ ChatErrorRemoteCtrl RCEInactive) pure - checkNoRemoteCtrlSession :: ChatMonad m => m () checkNoRemoteCtrlSession = chatReadVar remoteCtrlSession >>= maybe (pure ()) (\_ -> throwError $ ChatErrorRemoteCtrl RCEBusy) -withRemoteCtrlSession :: ChatMonad m => (RemoteCtrlSession -> Either ChatError (a, RemoteCtrlSession)) -> m a -withRemoteCtrlSession state = withRemoteCtrlSession_ $ maybe (Left $ ChatErrorRemoteCtrl RCEInactive) ((second . second) Just . state) - --- | Atomically process controller state wrt. specific remote ctrl session -withRemoteCtrlSession_ :: ChatMonad m => (Maybe RemoteCtrlSession -> Either ChatError (a, Maybe RemoteCtrlSession)) -> m a -withRemoteCtrlSession_ state = do +-- | Transition controller to a new state, unless session update key is stale +updateRemoteCtrlSession :: ChatMonad m => SessionSeq -> (RemoteCtrlSession -> Either ChatError RemoteCtrlSession) -> m () +updateRemoteCtrlSession sseq state = do session <- asks remoteCtrlSession - r <- - atomically $ stateTVar session $ \s -> - case state s of - Left e -> (Left e, s) - Right (a, s') -> (Right a, s') + r <- atomically $ do + readTVar session >>= \case + Nothing -> pure . Left $ ChatErrorRemoteCtrl RCEInactive + Just (oldSeq, st) + | oldSeq /= sseq -> pure . Left $ ChatErrorRemoteCtrl RCEBadState + | otherwise -> case state st of + Left ce -> pure $ Left ce + Right st' -> Right () <$ writeTVar session (Just (sseq, st')) liftEither r -updateRemoteCtrlSession :: ChatMonad m => (RemoteCtrlSession -> Either ChatError RemoteCtrlSession) -> m () -updateRemoteCtrlSession state = withRemoteCtrlSession $ fmap ((),) . state - utf8String :: [Char] -> ByteString utf8String = encodeUtf8 . T.pack {-# INLINE utf8String #-} diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index c56b2462b0..783a083e55 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -56,6 +56,8 @@ data RemoteSignatures sessPubKey :: C.PublicKeyEd25519 } +type SessionSeq = Int + data RHPendingSession = RHPendingSession { rhKey :: RHKey, rchClient :: RCHostClient, diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index dc2f890a7f..c734c94dbe 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -185,7 +185,7 @@ remoteStoreFileTest = rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) desktopHostStore <- case M.lookup (RHId 1) rhs of - Just RHSessionConnected {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder _ -> fail "Host session 1 should be started" desktop ##> "/store remote file 1 tests/fixtures/test.pdf" desktop <## "file test.pdf stored on remote host 1" @@ -311,7 +311,7 @@ remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) desktopHostStore <- case M.lookup (RHId 1) rhs of - Just RHSessionConnected {storePath} -> pure $ desktopHostFiles storePath archiveFilesFolder + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder _ -> fail "Host session 1 should be started" mobileName <- userName mobile From 5b7de8f8c11b5bdaa0a0ff07dfb59a9bee4bcd88 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:20:10 +0000 Subject: [PATCH 223/294] desktop, android: pass remote host to API from the loaded objects, to prevent race conditions (#3397) * desktop, android: pass remote host explicitely to API calls * use remote host ID in model updates * add remote host to chat console * add remote host to notifications functions --- .../java/chat/simplex/app/MainActivity.kt | 2 +- .../main/java/chat/simplex/app/SimplexApp.kt | 2 +- .../common/views/call/CallView.android.kt | 11 +- .../newchat/ConnectViaLinkView.android.kt | 7 +- .../newchat/ScanToConnectView.android.kt | 3 +- .../ScanProtocolServer.android.kt | 4 +- .../kotlin/chat/simplex/common/App.kt | 2 +- .../chat/simplex/common/model/ChatModel.kt | 116 ++-- .../chat/simplex/common/model/SimpleXAPI.kt | 622 +++++++++--------- .../chat/simplex/common/platform/Core.kt | 2 +- .../simplex/common/platform/NtfManager.kt | 11 +- .../chat/simplex/common/views/TerminalView.kt | 11 +- .../chat/simplex/common/views/WelcomeView.kt | 9 +- .../simplex/common/views/call/CallManager.kt | 5 +- .../chat/simplex/common/views/call/WebRTC.kt | 10 +- .../simplex/common/views/chat/ChatInfoView.kt | 60 +- .../simplex/common/views/chat/ChatView.kt | 94 +-- .../simplex/common/views/chat/ComposeView.kt | 27 +- .../common/views/chat/ContactPreferences.kt | 5 +- .../views/chat/group/AddGroupMembersView.kt | 8 +- .../views/chat/group/GroupChatInfoView.kt | 55 +- .../common/views/chat/group/GroupLinkView.kt | 7 +- .../views/chat/group/GroupMemberInfoView.kt | 68 +- .../views/chat/group/GroupPreferences.kt | 12 +- .../views/chat/group/GroupProfileView.kt | 6 +- .../views/chat/group/WelcomeMessageView.kt | 6 +- .../views/chatlist/ChatListNavLinkView.kt | 157 ++--- .../common/views/chatlist/ChatListView.kt | 9 +- .../views/chatlist/ShareListNavLinkView.kt | 4 +- .../common/views/chatlist/UserPicker.kt | 4 +- .../common/views/database/DatabaseView.kt | 18 +- .../simplex/common/views/helpers/Utils.kt | 2 +- .../common/views/localauth/LocalAuthView.kt | 2 +- .../common/views/newchat/AddContactView.kt | 8 +- .../common/views/newchat/AddGroupView.kt | 12 +- .../views/newchat/ConnectViaLinkView.kt | 2 +- .../newchat/ContactConnectionInfoView.kt | 11 +- .../common/views/newchat/CreateLinkView.kt | 11 +- .../common/views/newchat/NewChatSheet.kt | 9 +- .../common/views/newchat/PasteToConnect.kt | 7 +- .../common/views/newchat/ScanToConnectView.kt | 82 +-- .../views/onboarding/CreateSimpleXAddress.kt | 16 +- .../onboarding/SetupDatabasePassphrase.kt | 2 +- .../views/usersettings/HiddenProfileView.kt | 2 +- .../views/usersettings/NetworkAndServers.kt | 4 +- .../common/views/usersettings/Preferences.kt | 6 +- .../views/usersettings/PrivacySettings.kt | 8 +- .../views/usersettings/ProtocolServerView.kt | 2 +- .../views/usersettings/ProtocolServersView.kt | 21 +- .../views/usersettings/ScanProtocolServer.kt | 6 +- .../usersettings/SetDeliveryReceiptsView.kt | 4 +- .../common/views/usersettings/SettingsView.kt | 2 +- .../views/usersettings/UserAddressView.kt | 10 +- .../views/usersettings/UserProfileView.kt | 4 +- .../views/usersettings/UserProfilesView.kt | 14 +- .../common/views/call/CallView.desktop.kt | 11 +- .../views/chatlist/ChatListView.desktop.kt | 2 +- .../newchat/ConnectViaLinkView.desktop.kt | 5 +- .../newchat/ScanToConnectView.desktop.kt | 3 +- .../ScanProtocolServer.desktop.kt | 4 +- 60 files changed, 853 insertions(+), 776 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 40c04f5088..4c5d595a83 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -126,7 +126,7 @@ fun processIntent(intent: Intent?) { when (intent?.action) { "android.intent.action.VIEW" -> { val uri = intent.data - if (uri != null) connectIfOpenedViaUri(uri.toURI(), ChatModel) + if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId, uri.toURI(), ChatModel) } } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index d2c4465172..13908f69bf 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -57,7 +57,7 @@ class SimplexApp: Application(), LifecycleEventObserver { updatingChatsMutex.withLock { kotlin.runCatching { val currentUserId = chatModel.currentUser.value?.userId - val chats = ArrayList(chatController.apiGetChats()) + val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId)) /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ if (chatModel.currentUser.value?.userId == currentUserId) { val currentChatId = chatModel.chatId.value diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 5c7b430ab3..c173463d5e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -115,22 +115,23 @@ actual fun ActiveCallView() { val call = chatModel.activeCall.value if (call != null) { Log.d(TAG, "has active call $call") + val callRh = call.remoteHostId when (val r = apiMsg.resp) { is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) - chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } is WCallResponse.Offer -> withBGApi { - chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } is WCallResponse.Answer -> withBGApi { - chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { @@ -139,7 +140,7 @@ actual fun ActiveCallView() { chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) setCallSound(call.soundSpeaker, audioViaBluetooth) } - withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Error) { Log.d(TAG,"call status ${r.state.connectionState} not used") } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt index dcb5055425..1faf115b37 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt @@ -12,7 +12,8 @@ import chat.simplex.common.model.ChatModel import chat.simplex.res.MR @Composable -actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { +actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { + // TODO this should close if remote host changes in model val selection = remember { mutableStateOf( runCatching { ConnectViaLinkTab.valueOf(m.controller.appPrefs.connectViaLinkTab.get()!!) }.getOrDefault(ConnectViaLinkTab.SCAN) @@ -31,10 +32,10 @@ actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { Column(Modifier.weight(1f)) { when (selection.value) { ConnectViaLinkTab.SCAN -> { - ScanToConnectView(m, close) + ScanToConnectView(m, rhId, close) } ConnectViaLinkTab.PASTE -> { - PasteToConnectView(m, close) + PasteToConnectView(m, rhId, close) } } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt index d0cad31210..89477e45a1 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt @@ -7,13 +7,14 @@ import chat.simplex.common.model.ChatModel import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() } ConnectContactLayout( chatModel = chatModel, + rhId = rhId, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt index a1b7b3141d..af5a27be11 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt @@ -7,10 +7,10 @@ import chat.simplex.common.model.ServerCfg import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { +actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() } - ScanProtocolServerLayout(onNext) + ScanProtocolServerLayout(rhId, onNext) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 41deee7a55..da74a37aaa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -138,7 +138,7 @@ fun MainScreen() { } onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) - onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) + onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) } if (appPlatform.isAndroid) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index af946be3cd..ab8b6af3fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -111,6 +111,7 @@ object ChatModel { // remote controller val remoteHosts = mutableStateListOf() val currentRemoteHost = mutableStateOf(null) + val remoteHostId: Long? get() = currentRemoteHost?.value?.remoteHostId val newRemoteHostPairing = mutableStateOf?>(null) val remoteCtrlSession = mutableStateOf(null) @@ -141,16 +142,17 @@ object ChatModel { } // toList() here is to prevent ConcurrentModificationException that is rarely happens but happens - fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null + fun hasChat(rhId: Long?, id: String): Boolean = chats.toList().firstOrNull { it.id == id && it.remoteHostId == rhId } != null + // TODO pass rhId? fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } fun getGroupMember(groupMemberId: Long): GroupMember? = groupMembers.firstOrNull { it.groupMemberId == groupMemberId } - private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id } + private fun getChatIndex(rhId: Long?, id: String): Int = chats.toList().indexOfFirst { it.id == id && it.remoteHostId == rhId } fun addChat(chat: Chat) = chats.add(index = 0, chat) - fun updateChatInfo(cInfo: ChatInfo) { - val i = getChatIndex(cInfo.id) + fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) { + val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { val currentCInfo = chats[i].chatInfo var newCInfo = cInfo @@ -172,23 +174,23 @@ object ChatModel { } } - fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection)) + fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection)) - fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed) + fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed) - fun updateContactConnectionStats(contact: Contact, connectionStats: ConnectionStats) { + fun updateContactConnectionStats(rhId: Long?, contact: Contact, connectionStats: ConnectionStats) { val updatedConn = contact.activeConn?.copy(connectionStats = connectionStats) val updatedContact = contact.copy(activeConn = updatedConn) - updateContact(updatedContact) + updateContact(rhId, updatedContact) } - fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo)) + fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo)) - private fun updateChat(cInfo: ChatInfo, addMissing: Boolean = true) { - if (hasChat(cInfo.id)) { - updateChatInfo(cInfo) + private fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { + if (hasChat(rhId, cInfo.id)) { + updateChatInfo(rhId, cInfo) } else if (addMissing) { - addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf())) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) } } @@ -203,8 +205,8 @@ object ChatModel { } } - fun replaceChat(id: String, chat: Chat) { - val i = getChatIndex(id) + fun replaceChat(rhId: Long?, id: String, chat: Chat) { + val i = getChatIndex(rhId, id) if (i >= 0) { chats[i] = chat } else { @@ -213,9 +215,9 @@ object ChatModel { } } - suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { + suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { // update previews - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { chat = chats[i] @@ -224,7 +226,7 @@ object ChatModel { chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId - increaseUnreadCounter(currentUser.value!!) + increaseUnreadCounter(rhId, currentUser.value!!) chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId) } else @@ -234,7 +236,7 @@ object ChatModel { popChat_(i) } } else { - addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) } Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") withContext(Dispatchers.Main) { @@ -254,9 +256,9 @@ object ChatModel { } } - suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { + suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { // update previews - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) val chat: Chat val res: Boolean if (i >= 0) { @@ -266,12 +268,12 @@ object ChatModel { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) if (pItem.isRcvNew && !cItem.isRcvNew) { // status changed from New to Read, update counter - decreaseCounterInChat(cInfo.id) + decreaseCounterInChat(rhId, cInfo.id) } } res = false } else { - addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) res = true } Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") @@ -313,12 +315,12 @@ object ChatModel { } } - fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) { + fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { if (cItem.isRcvNew) { - decreaseCounterInChat(cInfo.id) + decreaseCounterInChat(rhId, cInfo.id) } // update previews - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { chat = chats[i] @@ -337,11 +339,11 @@ object ChatModel { } } - fun clearChat(cInfo: ChatInfo) { + fun clearChat(rhId: Long?, cInfo: ChatInfo) { // clear preview - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { - decreaseUnreadCounter(currentUser.value!!, chats[i].chatStats.unreadCount) + decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) } // clear current chat @@ -351,15 +353,15 @@ object ChatModel { } } - fun updateCurrentUser(newProfile: Profile, preferences: FullChatPreferences? = null) { + fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return val updated = current.copy( profile = newProfile.toLocalProfile(current.profile.profileId), fullPreferences = preferences ?: current.fullPreferences ) - val indexInUsers = users.indexOfFirst { it.user.userId == current.userId } - if (indexInUsers != -1) { - users[indexInUsers] = UserInfo(updated, users[indexInUsers].unreadCount) + val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId } + if (i != -1) { + users[i] = users[i].copy(user = updated) } currentUser.value = updated } @@ -378,16 +380,17 @@ object ChatModel { } } - fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { - val markedRead = markItemsReadInCurrentChat(cInfo, range) + fun markChatItemsRead(chat: Chat, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { + val cInfo = chat.chatInfo + val markedRead = markItemsReadInCurrentChat(chat, range) // update preview - val chatIdx = getChatIndex(cInfo.id) + val chatIdx = getChatIndex(chat.remoteHostId, cInfo.id) if (chatIdx >= 0) { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id if (lastId != null) { val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0 - decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) + decreaseUnreadCounter(chat.remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, @@ -399,7 +402,8 @@ object ChatModel { } } - private fun markItemsReadInCurrentChat(cInfo: ChatInfo, range: CC.ItemRange? = null): Int { + private fun markItemsReadInCurrentChat(chat: Chat, range: CC.ItemRange? = null): Int { + val cInfo = chat.chatInfo var markedRead = 0 if (chatId.value == cInfo.id) { var i = 0 @@ -423,13 +427,13 @@ object ChatModel { return markedRead } - private fun decreaseCounterInChat(chatId: ChatId) { - val chatIndex = getChatIndex(chatId) + private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) { + val chatIndex = getChatIndex(rhId, chatId) if (chatIndex == -1) return val chat = chats[chatIndex] val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) - decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) + decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIndex] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, @@ -437,18 +441,18 @@ object ChatModel { ) } - fun increaseUnreadCounter(user: UserLike) { - changeUnreadCounter(user, 1) + fun increaseUnreadCounter(rhId: Long?, user: UserLike) { + changeUnreadCounter(rhId, user, 1) } - fun decreaseUnreadCounter(user: UserLike, by: Int = 1) { - changeUnreadCounter(user, -by) + fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { + changeUnreadCounter(rhId, user, -by) } - private fun changeUnreadCounter(user: UserLike, by: Int) { - val i = users.indexOfFirst { it.user.userId == user.userId } + private fun changeUnreadCounter(rhId: Long?, user: UserLike, by: Int) { + val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } if (i != -1) { - users[i] = UserInfo(users[i].user, users[i].unreadCount + by) + users[i] = users[i].copy(unreadCount = users[i].unreadCount + by) } } @@ -544,14 +548,14 @@ object ChatModel { } } - fun removeChat(id: String) { - chats.removeAll { it.id == id } + fun removeChat(rhId: Long?, id: String) { + chats.removeAll { it.id == id && it.remoteHostId == rhId } } - fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean { + fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { // user member was updated if (groupInfo.membership.groupMemberId == member.groupMemberId) { - updateGroup(groupInfo) + updateGroup(rhId, groupInfo) return false } // update current chat @@ -569,12 +573,12 @@ object ChatModel { } } - fun updateGroupMemberConnectionStats(groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { + fun updateGroupMemberConnectionStats(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { val memberConn = member.activeConn if (memberConn != null) { val updatedConn = memberConn.copy(connectionStats = connectionStats) val updatedMember = member.copy(activeConn = updatedConn) - upsertGroupMember(groupInfo, updatedMember) + upsertGroupMember(rhId, groupInfo, updatedMember) } } @@ -612,6 +616,7 @@ enum class ChatType(val type: String) { @Serializable data class User( + val remoteHostId: Long? = null, override val userId: Long, val userContactId: Long, val localDisplayName: String, @@ -711,9 +716,10 @@ interface SomeChat { @Serializable @Stable data class Chat ( + val remoteHostId: Long? = null, val chatInfo: ChatInfo, val chatItems: List, - val chatStats: ChatStats = ChatStats(), + val chatStats: ChatStats = ChatStats() ) { val userCanSend: Boolean get() = when (chatInfo) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 253a9fb163..ffb5b4251b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -4,6 +4,7 @@ import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import chat.simplex.common.model.ChatModel.remoteHostId import chat.simplex.common.model.ChatModel.updatingChatsMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* @@ -352,13 +353,13 @@ object ChatController { apiSetXFTPConfig(getXFTPCfg()) apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() - val users = listUsers() + val users = listUsers(null) chatModel.users.clear() chatModel.users.addAll(users) if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true - getUserChatData() + getUserChatData(null) appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true startReceiver() @@ -366,7 +367,7 @@ object ChatController { Log.d(TAG, "startChat: started") } else { updatingChatsMutex.withLock { - val chats = apiGetChats() + val chats = apiGetChats(null) chatModel.updateChats(chats) } Log.d(TAG, "startChat: running") @@ -377,33 +378,33 @@ object ChatController { } } - suspend fun changeActiveUser(toUserId: Long, viewPwd: String?) { + suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) { try { - changeActiveUser_(toUserId, viewPwd) + changeActiveUser_(rhId, toUserId, viewPwd) } catch (e: Exception) { Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_active_user_title), e.stackTraceToString()) } } - suspend fun changeActiveUser_(toUserId: Long, viewPwd: String?) { - val currentUser = apiSetActiveUser(toUserId, viewPwd) + suspend fun changeActiveUser_(rhId: Long?, toUserId: Long, viewPwd: String?) { + val currentUser = apiSetActiveUser(rhId, toUserId, viewPwd) chatModel.currentUser.value = currentUser - val users = listUsers() + val users = listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - getUserChatData() + getUserChatData(rhId) val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId } if (invitation != null) { chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser)) } } - suspend fun getUserChatData() { - chatModel.userAddress.value = apiGetUserAddress() - chatModel.chatItemTTL.value = getChatItemTTL() + suspend fun getUserChatData(rhId: Long?) { + chatModel.userAddress.value = apiGetUserAddress(rhId) + chatModel.chatItemTTL.value = getChatItemTTL(rhId) updatingChatsMutex.withLock { - val chats = apiGetChats() + val chats = apiGetChats(rhId) chatModel.updateChats(chats) } } @@ -428,21 +429,20 @@ object ChatController { } } - suspend fun sendCmd(cmd: CC, customRhId: Long? = null): CR { + suspend fun sendCmd(rhId: Long?, cmd: CC): CR { val ctrl = ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString - chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated)) + chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") - val rhId = customRhId?.toInt() ?: chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1 - val json = if (rhId == -1) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId, c) + val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c) val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") if (r.resp is CR.Response || r.resp is CR.Invalid) { Log.d(TAG, "sendCmd response json $json") } - chatModel.addTerminalItem(TerminalItem.resp(r.resp)) + chatModel.addTerminalItem(TerminalItem.resp(rhId, r.resp)) r.resp } } @@ -460,16 +460,16 @@ object ChatController { } } - suspend fun apiGetActiveUser(): User? { - val r = sendCmd(CC.ShowActiveUser()) + suspend fun apiGetActiveUser(rh: Long?): User? { + val r = sendCmd(rh, CC.ShowActiveUser()) if (r is CR.ActiveUser) return r.user Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") chatModel.userCreated.value = false return null } - suspend fun apiCreateActiveUser(p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? { - val r = sendCmd(CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp)) + suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? { + val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp)) if (r is CR.ActiveUser) return r.user else if ( r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName || @@ -483,65 +483,68 @@ object ChatController { return null } - suspend fun listUsers(): List { - val r = sendCmd(CC.ListUsers()) - if (r is CR.UsersList) return r.users.sortedBy { it.user.chatViewName } + suspend fun listUsers(rh: Long?): List { + val r = sendCmd(rh, CC.ListUsers()) + if (r is CR.UsersList) { + val users = if (rh == null) r.users else r.users.map { it.copy(user = it.user.copy(remoteHostId = rh)) } + return users.sortedBy { it.user.chatViewName } + } Log.d(TAG, "listUsers: ${r.responseType} ${r.details}") throw Exception("failed to list users ${r.responseType} ${r.details}") } - suspend fun apiSetActiveUser(userId: Long, viewPwd: String?): User { - val r = sendCmd(CC.ApiSetActiveUser(userId, viewPwd)) - if (r is CR.ActiveUser) return r.user + suspend fun apiSetActiveUser(rh: Long?, userId: Long, viewPwd: String?): User { + val r = sendCmd(rh, CC.ApiSetActiveUser(userId, viewPwd)) + if (r is CR.ActiveUser) return if (rh == null) r.user else r.user.copy(remoteHostId = rh) Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}") throw Exception("failed to set the user as active ${r.responseType} ${r.details}") } - suspend fun apiSetAllContactReceipts(enable: Boolean) { - val r = sendCmd(CC.SetAllContactReceipts(enable)) + suspend fun apiSetAllContactReceipts(rh: Long?, enable: Boolean) { + val r = sendCmd(rh, CC.SetAllContactReceipts(enable)) if (r is CR.CmdOk) return throw Exception("failed to set receipts for all users ${r.responseType} ${r.details}") } - suspend fun apiSetUserContactReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) { - val r = sendCmd(CC.ApiSetUserContactReceipts(userId, userMsgReceiptSettings)) + suspend fun apiSetUserContactReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { + val r = sendCmd(u.remoteHostId, CC.ApiSetUserContactReceipts(u.userId, userMsgReceiptSettings)) if (r is CR.CmdOk) return throw Exception("failed to set receipts for user contacts ${r.responseType} ${r.details}") } - suspend fun apiSetUserGroupReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) { - val r = sendCmd(CC.ApiSetUserGroupReceipts(userId, userMsgReceiptSettings)) + suspend fun apiSetUserGroupReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { + val r = sendCmd(u.remoteHostId, CC.ApiSetUserGroupReceipts(u.userId, userMsgReceiptSettings)) if (r is CR.CmdOk) return throw Exception("failed to set receipts for user groups ${r.responseType} ${r.details}") } - suspend fun apiHideUser(userId: Long, viewPwd: String): User = - setUserPrivacy(CC.ApiHideUser(userId, viewPwd)) + suspend fun apiHideUser(u: User, viewPwd: String): User = + setUserPrivacy(u.remoteHostId, CC.ApiHideUser(u.userId, viewPwd)) - suspend fun apiUnhideUser(userId: Long, viewPwd: String): User = - setUserPrivacy(CC.ApiUnhideUser(userId, viewPwd)) + suspend fun apiUnhideUser(u: User, viewPwd: String): User = + setUserPrivacy(u.remoteHostId, CC.ApiUnhideUser(u.userId, viewPwd)) - suspend fun apiMuteUser(userId: Long): User = - setUserPrivacy(CC.ApiMuteUser(userId)) + suspend fun apiMuteUser(u: User): User = + setUserPrivacy(u.remoteHostId, CC.ApiMuteUser(u.userId)) - suspend fun apiUnmuteUser(userId: Long): User = - setUserPrivacy(CC.ApiUnmuteUser(userId)) + suspend fun apiUnmuteUser(u: User): User = + setUserPrivacy(u.remoteHostId, CC.ApiUnmuteUser(u.userId)) - private suspend fun setUserPrivacy(cmd: CC): User { - val r = sendCmd(cmd) - if (r is CR.UserPrivacy) return r.updatedUser + private suspend fun setUserPrivacy(rh: Long?, cmd: CC): User { + val r = sendCmd(rh, cmd) + if (r is CR.UserPrivacy) return if (rh == null) r.updatedUser else r.updatedUser.copy(remoteHostId = rh) else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}") } - suspend fun apiDeleteUser(userId: Long, delSMPQueues: Boolean, viewPwd: String?) { - val r = sendCmd(CC.ApiDeleteUser(userId, delSMPQueues, viewPwd)) + suspend fun apiDeleteUser(u: User, delSMPQueues: Boolean, viewPwd: String?) { + val r = sendCmd(u.remoteHostId, CC.ApiDeleteUser(u.userId, delSMPQueues, viewPwd)) if (r is CR.CmdOk) return Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}") throw Exception("failed to delete the user ${r.responseType} ${r.details}") } suspend fun apiStartChat(): Boolean { - val r = sendCmd(CC.StartChat(expire = true)) + val r = sendCmd(null, CC.StartChat(expire = true)) when (r) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -550,7 +553,7 @@ object ChatController { } suspend fun apiStopChat(): Boolean { - val r = sendCmd(CC.ApiStopChat()) + val r = sendCmd(null, CC.ApiStopChat()) when (r) { is CR.ChatStopped -> return true else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}") @@ -558,76 +561,76 @@ object ChatController { } private suspend fun apiSetTempFolder(tempFolder: String) { - val r = sendCmd(CC.SetTempFolder(tempFolder)) + val r = sendCmd(null, CC.SetTempFolder(tempFolder)) if (r is CR.CmdOk) return throw Error("failed to set temp folder: ${r.responseType} ${r.details}") } private suspend fun apiSetFilesFolder(filesFolder: String) { - val r = sendCmd(CC.SetFilesFolder(filesFolder)) + val r = sendCmd(null, CC.SetFilesFolder(filesFolder)) if (r is CR.CmdOk) return throw Error("failed to set files folder: ${r.responseType} ${r.details}") } private suspend fun apiSetRemoteHostsFolder(remoteHostsFolder: String) { - val r = sendCmd(CC.SetRemoteHostsFolder(remoteHostsFolder)) + val r = sendCmd(null, CC.SetRemoteHostsFolder(remoteHostsFolder)) if (r is CR.CmdOk) return throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}") } suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) { - val r = sendCmd(CC.ApiSetXFTPConfig(cfg)) + val r = sendCmd(null, CC.ApiSetXFTPConfig(cfg)) if (r is CR.CmdOk) return throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}") } - suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable)) suspend fun apiExportArchive(config: ArchiveConfig) { - val r = sendCmd(CC.ApiExportArchive(config)) + val r = sendCmd(null, CC.ApiExportArchive(config)) if (r is CR.CmdOk) return throw Error("failed to export archive: ${r.responseType} ${r.details}") } suspend fun apiImportArchive(config: ArchiveConfig): List { - val r = sendCmd(CC.ApiImportArchive(config)) + val r = sendCmd(null, CC.ApiImportArchive(config)) if (r is CR.ArchiveImported) return r.archiveErrors throw Error("failed to import archive: ${r.responseType} ${r.details}") } suspend fun apiDeleteStorage() { - val r = sendCmd(CC.ApiDeleteStorage()) + val r = sendCmd(null, CC.ApiDeleteStorage()) if (r is CR.CmdOk) return throw Error("failed to delete storage: ${r.responseType} ${r.details}") } suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): CR.ChatCmdError? { - val r = sendCmd(CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey))) + val r = sendCmd(null, CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey))) if (r is CR.CmdOk) return null else if (r is CR.ChatCmdError) return r throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } - suspend fun apiGetChats(): List { + suspend fun apiGetChats(rh: Long?): List { val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() } - val r = sendCmd(CC.ApiGetChats(userId)) - if (r is CR.ApiChats) return r.chats + val r = sendCmd(rh, CC.ApiGetChats(userId)) + if (r is CR.ApiChats) return if (rh == null) r.chats else r.chats.map { it.copy(remoteHostId = rh) } Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chats_title), generalGetString(MR.strings.contact_developers)) return emptyList() } - suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? { - val r = sendCmd(CC.ApiGetChat(type, id, pagination, search)) - if (r is CR.ApiChat) return r.chat + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? { + val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) + if (r is CR.ApiChat) return if (rh == null) r.chat else r.chat.copy(remoteHostId = rh) Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers)) return null } - suspend fun apiSendMessage(rhId: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + suspend fun apiSendMessage(rh: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) - val r = sendCmd(cmd, rhId) + val r = sendCmd(rh, cmd) return when (r) { is CR.NewChatItem -> r.chatItem else -> { @@ -639,8 +642,8 @@ object ChatController { } } - suspend fun apiGetChatItemInfo(type: ChatType, id: Long, itemId: Long): ChatItemInfo? { - return when (val r = sendCmd(CC.ApiGetChatItemInfo(type, id, itemId))) { + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { + return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { is CR.ApiChatItemInfo -> r.chatItemInfo else -> { apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) @@ -649,38 +652,38 @@ object ChatController { } } - suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { - val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc, live)) + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { + val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) if (r is CR.ChatItemUpdated) return r.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiChatItemReaction(type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { - val r = sendCmd(CC.ApiChatItemReaction(type, id, itemId, add, reaction)) + suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { + val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, itemId, add, reaction)) if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? { - val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode)) + suspend fun apiDeleteChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? { + val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, itemId, mode)) if (r is CR.ChatItemDeleted) return r Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiDeleteMemberChatItem(groupId: Long, groupMemberId: Long, itemId: Long): Pair? { - val r = sendCmd(CC.ApiDeleteMemberChatItem(groupId, groupMemberId, itemId)) + suspend fun apiDeleteMemberChatItem(rh: Long?, groupId: Long, groupMemberId: Long, itemId: Long): Pair? { + val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, groupMemberId, itemId)) if (r is CR.ChatItemDeleted) return r.deletedChatItem.chatItem to r.toChatItem?.chatItem Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun getUserProtoServers(serverProtocol: ServerProtocol): UserProtocolServers? { + suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? { val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null } - val r = sendCmd(CC.APIGetUserProtoServers(userId, serverProtocol)) - return if (r is CR.UserProtoServers) r.servers + val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol)) + return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) } else { Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg( @@ -691,9 +694,9 @@ object ChatController { } } - suspend fun setUserProtoServers(serverProtocol: ServerProtocol, servers: List): Boolean { + suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List): Boolean { val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false } - val r = sendCmd(CC.APISetUserProtoServers(userId, serverProtocol, servers)) + val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers)) return when (r) { is CR.CmdOk -> true else -> { @@ -707,9 +710,9 @@ object ChatController { } } - suspend fun testProtoServer(server: String): ProtocolTestFailure? { + suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") - val r = sendCmd(CC.APITestProtoServer(userId, server)) + val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) return when (r) { is CR.ServerTestResult -> r.testFailure else -> { @@ -719,29 +722,22 @@ object ChatController { } } - suspend fun getChatItemTTL(): ChatItemTTL { + suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") - val r = sendCmd(CC.APIGetChatItemTTL(userId)) + val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL) throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}") } - suspend fun setChatItemTTL(chatItemTTL: ChatItemTTL) { + suspend fun setChatItemTTL(rh: Long?, chatItemTTL: ChatItemTTL) { val userId = currentUserId("setChatItemTTL") - val r = sendCmd(CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) + val r = sendCmd(rh, CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) if (r is CR.CmdOk) return throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } - suspend fun apiGetNetworkConfig(): NetCfg? { - val r = sendCmd(CC.APIGetNetworkConfig()) - if (r is CR.NetworkConfig) return r.networkConfig - Log.e(TAG, "apiGetNetworkConfig bad response: ${r.responseType} ${r.details}") - return null - } - suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean { - val r = sendCmd(CC.APISetNetworkConfig(cfg)) + val r = sendCmd(null, CC.APISetNetworkConfig(cfg)) return when (r) { is CR.CmdOk -> true else -> { @@ -755,8 +751,8 @@ object ChatController { } } - suspend fun apiSetSettings(type: ChatType, id: Long, settings: ChatSettings): Boolean { - val r = sendCmd(CC.APISetChatSettings(type, id, settings)) + suspend fun apiSetSettings(rh: Long?, type: ChatType, id: Long, settings: ChatSettings): Boolean { + val r = sendCmd(rh, CC.APISetChatSettings(type, id, settings)) return when (r) { is CR.CmdOk -> true else -> { @@ -766,88 +762,88 @@ object ChatController { } } - suspend fun apiSetMemberSettings(groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = - sendCommandOkResp(CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) + suspend fun apiSetMemberSettings(rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = + sendCommandOkResp(rh, CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) - suspend fun apiContactInfo(contactId: Long): Pair? { - val r = sendCmd(CC.APIContactInfo(contactId)) + suspend fun apiContactInfo(rh: Long?, contactId: Long): Pair? { + val r = sendCmd(rh, CC.APIContactInfo(contactId)) if (r is CR.ContactInfo) return r.connectionStats_ to r.customUserProfile Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APIGroupMemberInfo(groupId, groupMemberId)) + suspend fun apiGroupMemberInfo(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APIGroupMemberInfo(groupId, groupMemberId)) if (r is CR.GroupMemberInfo) return Pair(r.member, r.connectionStats_) Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiSwitchContact(contactId: Long): ConnectionStats? { - val r = sendCmd(CC.APISwitchContact(contactId)) + suspend fun apiSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { + val r = sendCmd(rh, CC.APISwitchContact(contactId)) if (r is CR.ContactSwitchStarted) return r.connectionStats apiErrorAlert("apiSwitchContact", generalGetString(MR.strings.error_changing_address), r) return null } - suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APISwitchGroupMember(groupId, groupMemberId)) + suspend fun apiSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APISwitchGroupMember(groupId, groupMemberId)) if (r is CR.GroupMemberSwitchStarted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiSwitchGroupMember", generalGetString(MR.strings.error_changing_address), r) return null } - suspend fun apiAbortSwitchContact(contactId: Long): ConnectionStats? { - val r = sendCmd(CC.APIAbortSwitchContact(contactId)) + suspend fun apiAbortSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { + val r = sendCmd(rh, CC.APIAbortSwitchContact(contactId)) if (r is CR.ContactSwitchAborted) return r.connectionStats apiErrorAlert("apiAbortSwitchContact", generalGetString(MR.strings.error_aborting_address_change), r) return null } - suspend fun apiAbortSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) + suspend fun apiAbortSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) if (r is CR.GroupMemberSwitchAborted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiAbortSwitchGroupMember", generalGetString(MR.strings.error_aborting_address_change), r) return null } - suspend fun apiSyncContactRatchet(contactId: Long, force: Boolean): ConnectionStats? { - val r = sendCmd(CC.APISyncContactRatchet(contactId, force)) + suspend fun apiSyncContactRatchet(rh: Long?, contactId: Long, force: Boolean): ConnectionStats? { + val r = sendCmd(rh, CC.APISyncContactRatchet(contactId, force)) if (r is CR.ContactRatchetSyncStarted) return r.connectionStats apiErrorAlert("apiSyncContactRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } - suspend fun apiSyncGroupMemberRatchet(groupId: Long, groupMemberId: Long, force: Boolean): Pair? { - val r = sendCmd(CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) + suspend fun apiSyncGroupMemberRatchet(rh: Long?, groupId: Long, groupMemberId: Long, force: Boolean): Pair? { + val r = sendCmd(rh, CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) if (r is CR.GroupMemberRatchetSyncStarted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiSyncGroupMemberRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } - suspend fun apiGetContactCode(contactId: Long): Pair? { - val r = sendCmd(CC.APIGetContactCode(contactId)) + suspend fun apiGetContactCode(rh: Long?, contactId: Long): Pair? { + val r = sendCmd(rh, CC.APIGetContactCode(contactId)) if (r is CR.ContactCode) return r.contact to r.connectionCode Log.e(TAG,"failed to get contact code: ${r.responseType} ${r.details}") return null } - suspend fun apiGetGroupMemberCode(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APIGetGroupMemberCode(groupId, groupMemberId)) + suspend fun apiGetGroupMemberCode(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APIGetGroupMemberCode(groupId, groupMemberId)) if (r is CR.GroupMemberCode) return r.member to r.connectionCode Log.e(TAG,"failed to get group member code: ${r.responseType} ${r.details}") return null } - suspend fun apiVerifyContact(contactId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(CC.APIVerifyContact(contactId, connectionCode))) { + suspend fun apiVerifyContact(rh: Long?, contactId: Long, connectionCode: String?): Pair? { + return when (val r = sendCmd(rh, CC.APIVerifyContact(contactId, connectionCode))) { is CR.ConnectionVerified -> r.verified to r.expectedCode else -> null } } - suspend fun apiVerifyGroupMember(groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) { + suspend fun apiVerifyGroupMember(rh: Long?, groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? { + return when (val r = sendCmd(rh, CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) { is CR.ConnectionVerified -> r.verified to r.expectedCode else -> null } @@ -855,12 +851,12 @@ object ChatController { - suspend fun apiAddContact(incognito: Boolean): Pair? { + suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiAddContact: no current user") return null } - val r = sendCmd(CC.APIAddContact(userId, incognito)) + val r = sendCmd(rh, CC.APIAddContact(userId, incognito)) return when (r) { is CR.Invitation -> r.connReqInvitation to r.connection else -> { @@ -872,27 +868,27 @@ object ChatController { } } - suspend fun apiSetConnectionIncognito(connId: Long, incognito: Boolean): PendingContactConnection? { - val r = sendCmd(CC.ApiSetConnectionIncognito(connId, incognito)) + suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? { + val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito)) if (r is CR.ConnectionIncognitoUpdated) return r.toConnection Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiConnectPlan(connReq: String): ConnectionPlan? { + suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } - val r = sendCmd(CC.APIConnectPlan(userId, connReq)) + val r = sendCmd(rh, CC.APIConnectPlan(userId, connReq)) if (r is CR.CRConnectionPlan) return r.connectionPlan Log.e(TAG, "apiConnectPlan bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean { + suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): Boolean { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnect: no current user") return false } - val r = sendCmd(CC.APIConnect(userId, incognito, connReq)) + val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq)) when { r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.ContactAlreadyExists -> { @@ -928,12 +924,12 @@ object ChatController { } } - suspend fun apiConnectContactViaAddress(incognito: Boolean, contactId: Long): Contact? { + suspend fun apiConnectContactViaAddress(rh: Long?, incognito: Boolean, contactId: Long): Contact? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnectContactViaAddress: no current user") return null } - val r = sendCmd(CC.ApiConnectContactViaAddress(userId, incognito, contactId)) + val r = sendCmd(rh, CC.ApiConnectContactViaAddress(userId, incognito, contactId)) when { r is CR.SentInvitationToContact -> return r.contact else -> { @@ -945,8 +941,8 @@ object ChatController { } } - suspend fun apiDeleteChat(type: ChatType, id: Long, notify: Boolean? = null): Boolean { - val r = sendCmd(CC.ApiDeleteChat(type, id, notify)) + suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean { + val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify)) when { r is CR.ContactDeleted && type == ChatType.Direct -> return true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true @@ -964,24 +960,16 @@ object ChatController { return false } - suspend fun apiClearChat(type: ChatType, id: Long): ChatInfo? { - val r = sendCmd(CC.ApiClearChat(type, id)) + suspend fun apiClearChat(rh: Long?, type: ChatType, id: Long): ChatInfo? { + val r = sendCmd(rh, CC.ApiClearChat(type, id)) if (r is CR.ChatCleared) return r.chatInfo Log.e(TAG, "apiClearChat bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiListContacts(): List? { - val userId = kotlin.runCatching { currentUserId("apiListContacts") }.getOrElse { return null } - val r = sendCmd(CC.ApiListContacts(userId)) - if (r is CR.ContactsList) return r.contacts - Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}") - return null - } - - suspend fun apiUpdateProfile(profile: Profile): Pair>? { + suspend fun apiUpdateProfile(rh: Long?, profile: Profile): Pair>? { val userId = kotlin.runCatching { currentUserId("apiUpdateProfile") }.getOrElse { return null } - val r = sendCmd(CC.ApiUpdateProfile(userId, profile)) + val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile)) if (r is CR.UserProfileNoChange) return profile to emptyList() if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) { @@ -991,39 +979,39 @@ object ChatController { return null } - suspend fun apiSetProfileAddress(on: Boolean): User? { + suspend fun apiSetProfileAddress(rh: Long?, on: Boolean): User? { val userId = try { currentUserId("apiSetProfileAddress") } catch (e: Exception) { return null } - return when (val r = sendCmd(CC.ApiSetProfileAddress(userId, on))) { + return when (val r = sendCmd(rh, CC.ApiSetProfileAddress(userId, on))) { is CR.UserProfileNoChange -> null is CR.UserProfileUpdated -> r.user else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}") } } - suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? { - val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs)) + suspend fun apiSetContactPrefs(rh: Long?, contactId: Long, prefs: ChatPreferences): Contact? { + val r = sendCmd(rh, CC.ApiSetContactPrefs(contactId, prefs)) if (r is CR.ContactPrefsUpdated) return r.toContact Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? { - val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias)) + suspend fun apiSetContactAlias(rh: Long?, contactId: Long, localAlias: String): Contact? { + val r = sendCmd(rh, CC.ApiSetContactAlias(contactId, localAlias)) if (r is CR.ContactAliasUpdated) return r.toContact Log.e(TAG, "apiSetContactAlias bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiSetConnectionAlias(connId: Long, localAlias: String): PendingContactConnection? { - val r = sendCmd(CC.ApiSetConnectionAlias(connId, localAlias)) + suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? { + val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias)) if (r is CR.ConnectionAliasUpdated) return r.toConnection Log.e(TAG, "apiSetConnectionAlias bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiCreateUserAddress(): String? { + suspend fun apiCreateUserAddress(rh: Long?): String? { val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null } - val r = sendCmd(CC.ApiCreateMyAddress(userId)) + val r = sendCmd(rh, CC.ApiCreateMyAddress(userId)) return when (r) { is CR.UserContactLinkCreated -> r.connReqContact else -> { @@ -1035,17 +1023,17 @@ object ChatController { } } - suspend fun apiDeleteUserAddress(): User? { + suspend fun apiDeleteUserAddress(rh: Long?): User? { val userId = try { currentUserId("apiDeleteUserAddress") } catch (e: Exception) { return null } - val r = sendCmd(CC.ApiDeleteMyAddress(userId)) + val r = sendCmd(rh, CC.ApiDeleteMyAddress(userId)) if (r is CR.UserContactLinkDeleted) return r.user Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}") return null } - private suspend fun apiGetUserAddress(): UserContactLinkRec? { + private suspend fun apiGetUserAddress(rh: Long?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("apiGetUserAddress") }.getOrElse { return null } - val r = sendCmd(CC.ApiShowMyAddress(userId)) + val r = sendCmd(rh, CC.ApiShowMyAddress(userId)) if (r is CR.UserContactLink) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound @@ -1056,9 +1044,9 @@ object ChatController { return null } - suspend fun userAddressAutoAccept(autoAccept: AutoAccept?): UserContactLinkRec? { + suspend fun userAddressAutoAccept(rh: Long?, autoAccept: AutoAccept?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("userAddressAutoAccept") }.getOrElse { return null } - val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept)) + val r = sendCmd(rh, CC.ApiAddressAutoAccept(userId, autoAccept)) if (r is CR.UserContactLinkUpdated) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound @@ -1069,8 +1057,8 @@ object ChatController { return null } - suspend fun apiAcceptContactRequest(incognito: Boolean, contactReqId: Long): Contact? { - val r = sendCmd(CC.ApiAcceptContact(incognito, contactReqId)) + suspend fun apiAcceptContactRequest(rh: Long?, incognito: Boolean, contactReqId: Long): Contact? { + val r = sendCmd(rh, CC.ApiAcceptContact(incognito, contactReqId)) return when { r is CR.AcceptingContactRequest -> r.contact r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent @@ -1091,76 +1079,76 @@ object ChatController { } } - suspend fun apiRejectContactRequest(contactReqId: Long): Boolean { - val r = sendCmd(CC.ApiRejectContact(contactReqId)) + suspend fun apiRejectContactRequest(rh: Long?, contactReqId: Long): Boolean { + val r = sendCmd(rh, CC.ApiRejectContact(contactReqId)) if (r is CR.ContactRequestRejected) return true Log.e(TAG, "apiRejectContactRequest bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiSendCallInvitation(contact: Contact, callType: CallType): Boolean { - val r = sendCmd(CC.ApiSendCallInvitation(contact, callType)) + suspend fun apiSendCallInvitation(rh: Long?, contact: Contact, callType: CallType): Boolean { + val r = sendCmd(rh, CC.ApiSendCallInvitation(contact, callType)) return r is CR.CmdOk } - suspend fun apiRejectCall(contact: Contact): Boolean { - val r = sendCmd(CC.ApiRejectCall(contact)) + suspend fun apiRejectCall(rh: Long?, contact: Contact): Boolean { + val r = sendCmd(rh, CC.ApiRejectCall(contact)) return r is CR.CmdOk } - suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean { + suspend fun apiSendCallOffer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean { val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates) val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession) - val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer)) + val r = sendCmd(rh, CC.ApiSendCallOffer(contact, callOffer)) return r is CR.CmdOk } - suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean { + suspend fun apiSendCallAnswer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean { val answer = WebRTCSession(rtcSession, rtcIceCandidates) - val r = sendCmd(CC.ApiSendCallAnswer(contact, answer)) + val r = sendCmd(rh, CC.ApiSendCallAnswer(contact, answer)) return r is CR.CmdOk } - suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: String): Boolean { + suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean { val extraInfo = WebRTCExtraInfo(rtcIceCandidates) - val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo)) + val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo)) return r is CR.CmdOk } - suspend fun apiEndCall(contact: Contact): Boolean { - val r = sendCmd(CC.ApiEndCall(contact)) + suspend fun apiEndCall(rh: Long?, contact: Contact): Boolean { + val r = sendCmd(rh, CC.ApiEndCall(contact)) return r is CR.CmdOk } - suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean { - val r = sendCmd(CC.ApiCallStatus(contact, status)) + suspend fun apiCallStatus(rh: Long?, contact: Contact, status: WebRTCCallStatus): Boolean { + val r = sendCmd(rh, CC.ApiCallStatus(contact, status)) return r is CR.CmdOk } - suspend fun apiGetNetworkStatuses(): List? { - val r = sendCmd(CC.ApiGetNetworkStatuses()) + suspend fun apiGetNetworkStatuses(rh: Long?): List? { + val r = sendCmd(rh, CC.ApiGetNetworkStatuses()) if (r is CR.NetworkStatuses) return r.networkStatuses Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { - val r = sendCmd(CC.ApiChatRead(type, id, range)) + suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, range: CC.ItemRange): Boolean { + val r = sendCmd(rh, CC.ApiChatRead(type, id, range)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiChatUnread(type: ChatType, id: Long, unreadChat: Boolean): Boolean { - val r = sendCmd(CC.ApiChatUnread(type, id, unreadChat)) + suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean { + val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatUnread bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiReceiveFile(rhId: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { + suspend fun apiReceiveFile(rh: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { // -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected - val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline), rhId ?: -1) + val r = sendCmd(rh, CC.ReceiveFile(fileId, encrypted, inline)) return when (r) { is CR.RcvFileAccepted -> r.chatItem is CR.RcvFileAcceptedSndCancelled -> { @@ -1188,16 +1176,16 @@ object ChatController { } } - suspend fun cancelFile(rhId: Long?, user: User, fileId: Long) { - val chatItem = apiCancelFile(fileId) + suspend fun cancelFile(rh: Long?, user: User, fileId: Long) { + val chatItem = apiCancelFile(rh, fileId) if (chatItem != null) { - chatItemSimpleUpdate(rhId, user, chatItem) + chatItemSimpleUpdate(rh, user, chatItem) cleanupFile(chatItem) } } - suspend fun apiCancelFile(fileId: Long): AChatItem? { - val r = sendCmd(CC.CancelFile(fileId)) + suspend fun apiCancelFile(rh: Long?, fileId: Long): AChatItem? { + val r = sendCmd(rh, CC.CancelFile(fileId)) return when (r) { is CR.SndFileCancelled -> r.chatItem is CR.RcvFileCancelled -> r.chatItem @@ -1208,16 +1196,16 @@ object ChatController { } } - suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { + suspend fun apiNewGroup(rh: Long?, incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } - val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile)) + val r = sendCmd(rh, CC.ApiNewGroup(userId, incognito, groupProfile)) if (r is CR.GroupCreated) return r.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { - val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole)) + suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { + val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole)) return when (r) { is CR.SentGroupInvitation -> r.member else -> { @@ -1229,14 +1217,14 @@ object ChatController { } } - suspend fun apiJoinGroup(groupId: Long) { - val r = sendCmd(CC.ApiJoinGroup(groupId)) + suspend fun apiJoinGroup(rh: Long?, groupId: Long) { + val r = sendCmd(rh, CC.ApiJoinGroup(groupId)) when (r) { is CR.UserAcceptedGroupSent -> - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rh, r.groupInfo) is CR.ChatCmdError -> { val e = r.chatError - suspend fun deleteGroup() { if (apiDeleteChat(ChatType.Group, groupId)) { chatModel.removeChat("#$groupId") } } + suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { chatModel.removeChat(rh, "#$groupId") } } if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) { deleteGroup() AlertManager.shared.showAlertMsg(generalGetString(MR.strings.alert_title_group_invitation_expired), generalGetString(MR.strings.alert_message_group_invitation_expired)) @@ -1251,8 +1239,8 @@ object ChatController { } } - suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? = - when (val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))) { + suspend fun apiRemoveMember(rh: Long?, groupId: Long, memberId: Long): GroupMember? = + when (val r = sendCmd(rh, CC.ApiRemoveMember(groupId, memberId))) { is CR.UserDeletedMember -> r.member else -> { if (!(networkErrorAlert(r))) { @@ -1262,8 +1250,8 @@ object ChatController { } } - suspend fun apiMemberRole(groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = - when (val r = sendCmd(CC.ApiMemberRole(groupId, memberId, memberRole))) { + suspend fun apiMemberRole(rh: Long?, groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = + when (val r = sendCmd(rh, CC.ApiMemberRole(groupId, memberId, memberRole))) { is CR.MemberRoleUser -> r.member else -> { if (!(networkErrorAlert(r))) { @@ -1273,22 +1261,22 @@ object ChatController { } } - suspend fun apiLeaveGroup(groupId: Long): GroupInfo? { - val r = sendCmd(CC.ApiLeaveGroup(groupId)) + suspend fun apiLeaveGroup(rh: Long?, groupId: Long): GroupInfo? { + val r = sendCmd(rh, CC.ApiLeaveGroup(groupId)) if (r is CR.LeftMemberUser) return r.groupInfo Log.e(TAG, "apiLeaveGroup bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiListMembers(groupId: Long): List { - val r = sendCmd(CC.ApiListMembers(groupId)) + suspend fun apiListMembers(rh: Long?, groupId: Long): List { + val r = sendCmd(rh, CC.ApiListMembers(groupId)) if (r is CR.GroupMembers) return r.group.members Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}") return emptyList() } - suspend fun apiUpdateGroup(groupId: Long, groupProfile: GroupProfile): GroupInfo? { - return when (val r = sendCmd(CC.ApiUpdateGroupProfile(groupId, groupProfile))) { + suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? { + return when (val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile))) { is CR.GroupUpdated -> r.toGroup is CR.ChatCmdError -> { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.chatError") @@ -1305,8 +1293,8 @@ object ChatController { } } - suspend fun apiCreateGroupLink(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { - return when (val r = sendCmd(CC.APICreateGroupLink(groupId, memberRole))) { + suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + return when (val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole))) { is CR.GroupLinkCreated -> r.connReqContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { @@ -1317,8 +1305,8 @@ object ChatController { } } - suspend fun apiGroupLinkMemberRole(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { - return when (val r = sendCmd(CC.APIGroupLinkMemberRole(groupId, memberRole))) { + suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + return when (val r = sendCmd(rh, CC.APIGroupLinkMemberRole(groupId, memberRole))) { is CR.GroupLink -> r.connReqContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { @@ -1329,8 +1317,8 @@ object ChatController { } } - suspend fun apiDeleteGroupLink(groupId: Long): Boolean { - return when (val r = sendCmd(CC.APIDeleteGroupLink(groupId))) { + suspend fun apiDeleteGroupLink(rh: Long?, groupId: Long): Boolean { + return when (val r = sendCmd(rh, CC.APIDeleteGroupLink(groupId))) { is CR.GroupLinkDeleted -> true else -> { if (!(networkErrorAlert(r))) { @@ -1341,8 +1329,8 @@ object ChatController { } } - suspend fun apiGetGroupLink(groupId: Long): Pair? { - return when (val r = sendCmd(CC.APIGetGroupLink(groupId))) { + suspend fun apiGetGroupLink(rh: Long?, groupId: Long): Pair? { + return when (val r = sendCmd(rh, CC.APIGetGroupLink(groupId))) { is CR.GroupLink -> r.connReqContact to r.memberRole else -> { Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") @@ -1351,8 +1339,8 @@ object ChatController { } } - suspend fun apiCreateMemberContact(groupId: Long, groupMemberId: Long): Contact? { - return when (val r = sendCmd(CC.APICreateMemberContact(groupId, groupMemberId))) { + suspend fun apiCreateMemberContact(rh: Long?, groupId: Long, groupMemberId: Long): Contact? { + return when (val r = sendCmd(rh, CC.APICreateMemberContact(groupId, groupMemberId))) { is CR.NewMemberContact -> r.contact else -> { if (!(networkErrorAlert(r))) { @@ -1363,8 +1351,8 @@ object ChatController { } } - suspend fun apiSendMemberContactInvitation(contactId: Long, mc: MsgContent): Contact? { - return when (val r = sendCmd(CC.APISendMemberContactInvitation(contactId, mc))) { + suspend fun apiSendMemberContactInvitation(rh: Long?, contactId: Long, mc: MsgContent): Contact? { + return when (val r = sendCmd(rh, CC.APISendMemberContactInvitation(contactId, mc))) { is CR.NewMemberContactSentInv -> r.contact else -> { if (!(networkErrorAlert(r))) { @@ -1375,18 +1363,18 @@ object ChatController { } } - suspend fun allowFeatureToContact(contact: Contact, feature: ChatFeature, param: Int? = null) { + suspend fun allowFeatureToContact(rh: Long?, contact: Contact, feature: ChatFeature, param: Int? = null) { val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param) - val toContact = apiSetContactPrefs(contact.contactId, prefs) + val toContact = apiSetContactPrefs(rh, contact.contactId, prefs) if (toContact != null) { - chatModel.updateContact(toContact) + chatModel.updateContact(rh, toContact) } } - suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(CC.SetLocalDeviceName(displayName)) + suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(null, CC.SetLocalDeviceName(displayName)) suspend fun listRemoteHosts(): List? { - val r = sendCmd(CC.ListRemoteHosts()) + val r = sendCmd(null, CC.ListRemoteHosts()) if (r is CR.RemoteHostList) return r.remoteHosts apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null @@ -1399,20 +1387,20 @@ object ChatController { } suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Pair? { - val r = sendCmd(CC.StartRemoteHost(rhId, multicast)) + val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast)) if (r is CR.RemoteHostStarted) return r.remoteHost_ to r.invitation apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) return null } suspend fun switchRemoteHost (rhId: Long?): RemoteHostInfo? { - val r = sendCmd(CC.SwitchRemoteHost(rhId)) + val r = sendCmd(null, CC.SwitchRemoteHost(rhId)) if (r is CR.CurrentRemoteHost) return r.remoteHost_ apiErrorAlert("switchRemoteHost", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun stopRemoteHost(rhId: Long?): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId)) + suspend fun stopRemoteHost(rhId: Long?): Boolean = sendCommandOkResp(null, CC.StopRemoteHost(rhId)) fun stopRemoteHostAndReloadHosts(h: RemoteHostInfo, switchToLocal: Boolean) { withBGApi { @@ -1425,55 +1413,55 @@ object ChatController { } } - suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId)) + suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(null, CC.DeleteRemoteHost(rhId)) suspend fun storeRemoteFile(rhId: Long, storeEncrypted: Boolean?, localPath: String): CryptoFile? { - val r = sendCmd(CC.StoreRemoteFile(rhId, storeEncrypted, localPath)) + val r = sendCmd(null, CC.StoreRemoteFile(rhId, storeEncrypted, localPath)) if (r is CR.RemoteFileStored) return r.remoteFileSource apiErrorAlert("storeRemoteFile", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCommandOkResp(CC.GetRemoteFile(rhId, file)) + suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCommandOkResp(null, CC.GetRemoteFile(rhId, file)) suspend fun connectRemoteCtrl(desktopAddress: String): Pair { - val r = sendCmd(CC.ConnectRemoteCtrl(desktopAddress)) + val r = sendCmd(null, CC.ConnectRemoteCtrl(desktopAddress)) if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null else if (r is CR.ChatCmdError) return null to r else throw Exception("connectRemoteCtrl error: ${r.responseType} ${r.details}") } - suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(CC.FindKnownRemoteCtrl()) + suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.FindKnownRemoteCtrl()) - suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rcId)) + suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.ConfirmRemoteCtrl(rcId)) suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { - val r = sendCmd(CC.VerifyRemoteCtrlSession(sessionCode)) + val r = sendCmd(null, CC.VerifyRemoteCtrlSession(sessionCode)) if (r is CR.RemoteCtrlConnected) return r.remoteCtrl apiErrorAlert("verifyRemoteCtrlSession", generalGetString(MR.strings.error_alert_title), r) return null } suspend fun listRemoteCtrls(): List? { - val r = sendCmd(CC.ListRemoteCtrls()) + val r = sendCmd(null, CC.ListRemoteCtrls()) if (r is CR.RemoteCtrlList) return r.remoteCtrls apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error_alert_title), r) return null } - suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(CC.StopRemoteCtrl()) + suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.StopRemoteCtrl()) - suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteCtrl(rcId)) + suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.DeleteRemoteCtrl(rcId)) - private suspend fun sendCommandOkResp(cmd: CC): Boolean { - val r = sendCmd(cmd) + private suspend fun sendCommandOkResp(rh: Long?, cmd: CC): Boolean { + val r = sendCmd(rh, cmd) val ok = r is CR.CmdOk if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) return ok } suspend fun apiGetVersion(): CoreVersionInfo? { - val r = sendCmd(CC.ShowVersion()) + val r = sendCmd(null, CC.ShowVersion()) return if (r is CR.VersionInfo) { r.versionInfo } else { @@ -1517,30 +1505,30 @@ object ChatController { val r = apiResp.resp val rhId = apiResp.remoteHostId fun active(user: UserLike): Boolean = activeUser(rhId, user) - chatModel.addTerminalItem(TerminalItem.resp(r)) + chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) when (r) { is CR.NewContactConnection -> { if (active(r.user)) { - chatModel.updateContactConnection(r.connection) + chatModel.updateContactConnection(rhId, r.connection) } } is CR.ContactConnectionDeleted -> { if (active(r.user)) { - chatModel.removeChat(r.connection.id) + chatModel.removeChat(rhId, r.connection.id) } } is CR.ContactDeletedByContact -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) } } is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.dismissConnReqView(conn.id) - chatModel.removeChat(conn.id) + chatModel.removeChat(rhId, conn.id) } } if (r.contact.directOrUsed) { @@ -1550,11 +1538,11 @@ object ChatController { } is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.dismissConnReqView(conn.id) - chatModel.removeChat(conn.id) + chatModel.removeChat(rhId, conn.id) } } } @@ -1562,31 +1550,31 @@ object ChatController { val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) if (active(r.user)) { - if (chatModel.hasChat(contactRequest.id)) { - chatModel.updateChatInfo(cInfo) + if (chatModel.hasChat(rhId, contactRequest.id)) { + chatModel.updateChatInfo(rhId, cInfo) } else { - chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf())) + chatModel.addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) } } ntfManager.notifyContactRequestReceived(r.user, cInfo) } is CR.ContactUpdated -> { - if (active(r.user) && chatModel.hasChat(r.toContact.id)) { + if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) { val cInfo = ChatInfo.Direct(r.toContact) - chatModel.updateChatInfo(cInfo) + chatModel.updateChatInfo(rhId, cInfo) } } is CR.GroupMemberUpdated -> { if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.toMember) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.toMember) } } is CR.ContactsMerged -> { - if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) { + if (active(r.user) && chatModel.hasChat(rhId, r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } - chatModel.removeChat(r.mergedContact.id) + chatModel.removeChat(rhId, r.mergedContact.id) } } is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) @@ -1594,7 +1582,7 @@ object ChatController { is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { - chatModel.updateContact(sub.contact) + chatModel.updateContact(rhId, sub.contact) } val err = sub.contactError if (err == null) { @@ -1618,9 +1606,9 @@ object ChatController { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem if (active(r.user)) { - chatModel.addChatItem(cInfo, cItem) + chatModel.addChatItem(rhId, cInfo, cItem) } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { - chatModel.increaseUnreadCounter(r.user) + chatModel.increaseUnreadCounter(rhId, r.user) } val file = cItem.file val mc = cItem.content.msgContent @@ -1631,7 +1619,7 @@ object ChatController { || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { withApi { receiveFile(rhId, r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) } } - if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) { + if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId != rhId)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } @@ -1652,7 +1640,7 @@ object ChatController { is CR.ChatItemDeleted -> { if (!active(r.user)) { if (r.toChatItem == null && r.deletedChatItem.chatItem.isRcvNew && r.deletedChatItem.chatInfo.ntfsEnabled) { - chatModel.decreaseUnreadCounter(r.user) + chatModel.decreaseUnreadCounter(rhId, r.user) } return } @@ -1671,76 +1659,76 @@ object ChatController { ) } if (r.toChatItem == null) { - chatModel.removeChatItem(cInfo, cItem) + chatModel.removeChatItem(rhId, cInfo, cItem) } else { - chatModel.upsertChatItem(cInfo, r.toChatItem.chatItem) + chatModel.upsertChatItem(rhId, cInfo, r.toChatItem.chatItem) } } is CR.ReceivedGroupInvitation -> { if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated + chatModel.updateGroup(rhId, r.groupInfo) // update so that repeat group invitations are not duplicated // TODO NtfManager.shared.notifyGroupInvitation } } is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) val conn = r.hostContact?.activeConn if (conn != null) { chatModel.dismissConnReqView(conn.id) - chatModel.removeChat(conn.id) + chatModel.removeChat(rhId, conn.id) } } is CR.GroupLinkConnecting -> { if (!active(r.user)) return - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) val hostConn = r.hostMember.activeConn if (hostConn != null) { chatModel.dismissConnReqView(hostConn.id) - chatModel.removeChat(hostConn.id) + chatModel.removeChat(rhId, hostConn.id) } } is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.DeletedMemberUser -> // TODO update user member if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) } is CR.DeletedMember -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.deletedMember) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) } is CR.LeftMember -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.MemberRole -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.MemberRoleUser -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.GroupDeleted -> // TODO update user member if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) } is CR.UserJoinedGroup -> if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) } is CR.JoinedGroupMember -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.ConnectedToGroupMember -> { if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } if (r.memberContact != null) { chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected()) @@ -1748,11 +1736,11 @@ object ChatController { } is CR.GroupUpdated -> if (active(r.user)) { - chatModel.updateGroup(r.toGroup) + chatModel.updateGroup(rhId, r.toGroup) } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) } is CR.RcvFileStart -> chatItemSimpleUpdate(rhId, r.user, r.chatItem) @@ -1789,7 +1777,7 @@ object ChatController { cleanupFile(r.chatItem) } is CR.CallInvitation -> { - chatModel.callManager.reportNewIncomingCall(r.callInvitation) + chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId)) } is CR.CallOffer -> { // TODO askConfirmation? @@ -1834,13 +1822,13 @@ object ChatController { } } is CR.ContactSwitch -> - chatModel.updateContactConnectionStats(r.contact, r.switchProgress.connectionStats) + chatModel.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) is CR.GroupMemberSwitch -> - chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.switchProgress.connectionStats) + chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) is CR.ContactRatchetSync -> - chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats) + chatModel.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) is CR.GroupMemberRatchetSync -> - chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) is CR.RemoteHostSessionCode -> { chatModel.newRemoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) } @@ -1850,9 +1838,11 @@ object ChatController { switchUIRemoteHost(r.remoteHost.remoteHostId) } is CR.RemoteHostStopped -> { - chatModel.currentRemoteHost.value = null chatModel.newRemoteHostPairing.value = null - switchUIRemoteHost(null) + if (chatModel.currentRemoteHost.value != null) { + chatModel.currentRemoteHost.value = null + switchUIRemoteHost(null) + } } is CR.RemoteCtrlFound -> { // TODO multicast @@ -1897,11 +1887,11 @@ object ChatController { val m = chatModel m.remoteCtrlSession.value = null withBGApi { - val users = listUsers() + val users = listUsers(null) m.users.clear() m.users.addAll(users) - getUserChatData() - val statuses = apiGetNetworkStatuses() + getUserChatData(null) + val statuses = apiGetNetworkStatuses(null) if (statuses != null) { chatModel.networkStatuses.clear() val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() @@ -1911,7 +1901,7 @@ object ChatController { } private fun activeUser(rhId: Long?, user: UserLike): Boolean = - rhId == chatModel.currentRemoteHost.value?.remoteHostId && user.userId == chatModel.currentUser.value?.userId + rhId == chatModel.remoteHostId && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { val call = chatModel.activeCall.value @@ -1929,20 +1919,20 @@ object ChatController { } } - suspend fun leaveGroup(groupId: Long) { - val groupInfo = apiLeaveGroup(groupId) + suspend fun leaveGroup(rh: Long?, groupId: Long) { + val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != null) { - chatModel.updateGroup(groupInfo) + chatModel.updateGroup(rh, groupInfo) } } - private suspend fun chatItemSimpleUpdate(rhId: Long?, user: UserLike, aChatItem: AChatItem) { + private suspend fun chatItemSimpleUpdate(rh: Long?, user: UserLike, aChatItem: AChatItem) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem val notify = { ntfManager.notifyMessageReceived(user, cInfo, cItem) } - if (!activeUser(rhId, user)) { + if (!activeUser(rh, user)) { notify() - } else if (chatModel.upsertChatItem(cInfo, cItem)) { + } else if (chatModel.upsertChatItem(rh, cInfo, cItem)) { notify() } } @@ -1969,22 +1959,23 @@ object ChatController { } suspend fun switchUIRemoteHost(rhId: Long?) { + // TODO lock the switch so that two switches can't run concurrently? chatModel.chatId.value = null chatModel.currentRemoteHost.value = switchRemoteHost(rhId) reloadRemoteHosts() - val user = apiGetActiveUser() - val users = listUsers() + val user = apiGetActiveUser(rhId) + val users = listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) chatModel.currentUser.value = user chatModel.userCreated.value = true - val statuses = apiGetNetworkStatuses() + val statuses = apiGetNetworkStatuses(rhId) if (statuses != null) { chatModel.networkStatuses.clear() val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() chatModel.networkStatuses.putAll(ss) } - getUserChatData() + getUserChatData(rhId) } fun getXFTPCfg(): XFTPFileConfig { @@ -2540,6 +2531,7 @@ data class UserProtocolServers( @Serializable data class ServerCfg( + val remoteHostId: Long? = null, val server: String, val preset: Boolean, val tested: Boolean? = null, @@ -3610,7 +3602,7 @@ private fun parseChatData(chat: JsonElement): Chat { val chatItems: List = chat.jsonObject["chatItems"]!!.jsonArray.map { decodeObject(ChatItem.serializer(), it) ?: parseChatItem(it) } - return Chat(chatInfo, chatItems, chatStats) + return Chat(remoteHostId = null, chatInfo, chatItems, chatStats) } private fun parseChatItem(j: JsonElement): ChatItem { @@ -3699,7 +3691,6 @@ sealed class CR { @Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR() @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: UserRef, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR() - @Serializable @SerialName("contactsList") class ContactsList(val user: UserRef, val contacts: List): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @@ -3853,7 +3844,6 @@ sealed class CR { is ChatItemNotChanged -> "chatItemNotChanged" is ChatItemReaction -> "chatItemReaction" is ChatItemDeleted -> "chatItemDeleted" - is ContactsList -> "contactsList" is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" @@ -4001,7 +3991,6 @@ sealed class CR { is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem)) is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}") is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser") - is ContactsList -> withUser(user, json.encodeToString(contacts)) is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) @@ -4138,28 +4127,29 @@ sealed class GroupLinkPlan { abstract class TerminalItem { abstract val id: Long + abstract val remoteHostId: Long? val date: Instant = Clock.System.now() abstract val label: String abstract val details: String - class Cmd(override val id: Long, val cmd: CC): TerminalItem() { + class Cmd(override val id: Long, override val remoteHostId: Long?, val cmd: CC): TerminalItem() { override val label get() = "> ${cmd.cmdString}" override val details get() = cmd.cmdString } - class Resp(override val id: Long, val resp: CR): TerminalItem() { + class Resp(override val id: Long, override val remoteHostId: Long?, val resp: CR): TerminalItem() { override val label get() = "< ${resp.responseType}" override val details get() = resp.details } companion object { val sampleData = listOf( - Cmd(0, CC.ShowActiveUser()), - Resp(1, CR.ActiveUser(User.sampleData)) + Cmd(0, null, CC.ShowActiveUser()), + Resp(1, null, CR.ActiveUser(User.sampleData)) ) - fun cmd(c: CC) = Cmd(System.currentTimeMillis(), c) - fun resp(r: CR) = Resp(System.currentTimeMillis(), r) + fun cmd(rhId: Long?, c: CC) = Cmd(System.currentTimeMillis(), rhId, c) + fun resp(rhId: Long?, r: CR) = Resp(System.currentTimeMillis(), rhId, r) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index c32137ee61..3d3a91cb32 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -53,7 +53,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } else if (startChat) { // If we migrated successfully means previous re-encryption process on database level finished successfully too if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) - val user = chatController.apiGetActiveUser() + val user = chatController.apiGetActiveUser(null) if (user == null) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index a03df5addb..06925e28a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -49,7 +49,8 @@ abstract class NtfManager { null } val apiId = chatId.replace("<@", "").toLongOrNull() ?: return - acceptContactRequest(incognito, apiId, cInfo, isCurrentUser, ChatModel) + // TODO include remote host in notification + acceptContactRequest(null, incognito, apiId, cInfo, isCurrentUser, ChatModel) cancelNotificationsForChat(chatId) } @@ -57,11 +58,12 @@ abstract class NtfManager { withBGApi { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { - chatModel.controller.changeActiveUser(userId, null) + // TODO include remote host ID in desktop notifications? + chatModel.controller.changeActiveUser(null, userId, null) } val cInfo = chatModel.getChat(chatId)?.chatInfo chatModel.clearOverlays.value = true - if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(cInfo, chatModel) + if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo, chatModel) } } @@ -69,7 +71,8 @@ abstract class NtfManager { withBGApi { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { - chatModel.controller.changeActiveUser(userId, null) + // TODO include remote host ID in desktop notifications? + chatModel.controller.changeActiveUser(null, userId, null) } chatModel.chatId.value = null chatModel.clearOverlays.value = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index a471b5645e..ec2082557a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -47,13 +47,14 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState) { val clipboard = LocalClipboardManager.current LazyColumn(state = listState, reverseLayout = true) { items(reversedTerminalItems) { item -> + val rhId = item.remoteHostId + val rhIdStr = if (rhId == null) "" else "$rhId " Text( - "${item.date.toString().subSequence(11, 19)} ${item.label}", + "$rhIdStr${item.date.toString().subSequence(11, 19)} ${item.label}", style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary), maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 504ecac89d..fb15f0aba4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -170,18 +170,19 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) { fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { + val rhId = chatModel.remoteHostId val user = chatModel.controller.apiCreateActiveUser( - Profile(displayName.trim(), "", null) + rhId, Profile(displayName.trim(), "", null) ) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) } else { - val users = chatModel.controller.listUsers() + val users = chatModel.controller.listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - chatModel.controller.getUserChatData() + chatModel.controller.getUserChatData(rhId) close() } } @@ -190,7 +191,7 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { chatModel.controller.apiCreateActiveUser( - Profile(displayName.trim(), "", null) + null, Profile(displayName.trim(), "", null) ) ?: return@withApi val onboardingStage = chatModel.controller.appPrefs.onboardingStage if (chatModel.users.isEmpty()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index f601776f98..d0c9a6e4c6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -43,6 +43,7 @@ class CallManager(val chatModel: ChatModel) { private fun justAcceptIncomingCall(invitation: RcvCallInvitation) { with (chatModel) { activeCall.value = Call( + remoteHostId = invitation.remoteHostId, contact = invitation.contact, callState = CallState.InvitationAccepted, localMedia = invitation.callType.media, @@ -76,7 +77,7 @@ class CallManager(val chatModel: ChatModel) { Log.d(TAG, "CallManager.endCall: ending call...") callCommand.add(WCallCommand.End) showCallView.value = false - controller.apiEndCall(call.contact) + controller.apiEndCall(call.remoteHostId, call.contact) activeCall.value = null } } @@ -90,7 +91,7 @@ class CallManager(val chatModel: ChatModel) { ntfManager.cancelCallNotification() } withApi { - if (!controller.apiRejectCall(invitation.contact)) { + if (!controller.apiRejectCall(invitation.remoteHostId, invitation.contact)) { Log.e(TAG, "apiRejectCall error") } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 4be49d4c07..64904ba7a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -11,6 +11,7 @@ import java.util.* import kotlin.collections.ArrayList data class Call( + val remoteHostId: Long? = null, val contact: Contact, val callState: CallState, val localMedia: CallMediaType, @@ -95,7 +96,14 @@ sealed class WCallResponse { @Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) @Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) -@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) { +@Serializable data class RcvCallInvitation( + val remoteHostId: Long? = null, + val user: User, + val contact: Contact, + val callType: CallType, + val sharedKey: String? = null, + val callTs: Instant +) { val callTypeText: String get() = generalGetString(when(callType.media) { CallMediaType.Video -> if (sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call CallMediaType.Audio -> if (sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index b7c5e66a68..5816c89524 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -61,6 +61,7 @@ fun ChatInfoView( val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) { mutableStateOf(chatModel.contactNetworkStatus(contact)) } + val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } ChatInfoLayout( chat, @@ -81,25 +82,25 @@ fun ChatInfoView( connectionCode, developerTools, onLocalAliasChanged = { - setContactAlias(chat.chatInfo.apiId, it, chatModel) + setContactAlias(chat, it, chatModel) }, openPreferences = { ModalManager.end.showCustomModal { close -> val user = chatModel.currentUser.value if (user != null) { - ContactPreferencesView(chatModel, user, contact.contactId, close) + ContactPreferencesView(chatModel, user, chatRh, contact.contactId, close) } } }, - deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) }, - clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, + deleteContact = { deleteContactDialog(chat, chatModel, close) }, + clearChat = { clearChatDialog(chat, chatModel, close) }, switchContactAddress = { showSwitchAddressAlert(switchAddress = { withApi { - val cStats = chatModel.controller.apiSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } close.invoke() } @@ -108,20 +109,20 @@ fun ChatInfoView( abortSwitchContactAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - val cStats = chatModel.controller.apiAbortSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiAbortSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } } }) }, syncContactConnection = { withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } close.invoke() } @@ -129,10 +130,10 @@ fun ChatInfoView( syncContactConnectionForce = { showSyncConnectionForceAlert(syncConnectionForce = { withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = true) + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = true) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } close.invoke() } @@ -146,9 +147,10 @@ fun ChatInfoView( connectionCode, ct.verified, verify = { code -> - chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r -> + chatModel.controller.apiVerifyContact(chatRh, ct.contactId, code)?.let { r -> val (verified, existingCode) = r chatModel.updateContact( + chatRh, ct.copy( activeConn = ct.activeConn?.copy( connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null @@ -195,7 +197,8 @@ sealed class SendReceipts { } } -fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)), @@ -206,7 +209,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> SectionItemView({ AlertManager.shared.hideAlert() withApi { - deleteContact(chatInfo, chatModel, close, notify = true) + deleteContact(chat, chatModel, close, notify = true) } }) { Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -215,7 +218,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> SectionItemView({ AlertManager.shared.hideAlert() withApi { - deleteContact(chatInfo, chatModel, close, notify = false) + deleteContact(chat, chatModel, close, notify = false) } }) { Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -225,7 +228,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> SectionItemView({ AlertManager.shared.hideAlert() withApi { - deleteContact(chatInfo, chatModel, close) + deleteContact(chat, chatModel, close) } }) { Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -242,11 +245,13 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> ) } -fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { +fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { + val chatInfo = chat.chatInfo withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId, notify) + val chatRh = chat.remoteHostId + val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify) if (r) { - chatModel.removeChat(chatInfo.id) + chatModel.removeChat(chatRh, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -257,16 +262,18 @@ fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? } } -fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun clearChatDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.clear_chat_question), text = generalGetString(MR.strings.clear_chat_warning), confirmText = generalGetString(MR.strings.clear_verb), onConfirm = { withApi { - val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId) + val chatRh = chat.remoteHostId + val updatedChatInfo = chatModel.controller.apiClearChat(chatRh, chatInfo.chatType, chatInfo.apiId) if (updatedChatInfo != null) { - chatModel.clearChat(updatedChatInfo) + chatModel.clearChat(chatRh, updatedChatInfo) ntfManager.cancelNotificationsForChat(chatInfo.id) close?.invoke() } @@ -669,9 +676,10 @@ fun ShareAddressButton(onClick: () -> Unit) { ) } -private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel) = withApi { - chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let { - chatModel.updateContact(it) +private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withApi { + val chatRh = chat.remoteHostId + chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { + chatModel.updateContact(chatRh, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 7097c77d1a..862212217f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -46,7 +46,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) } val searchText = rememberSaveable { mutableStateOf("") } val user = chatModel.currentUser.value - val rhId = remember { chatModel.currentRemoteHost }.value?.remoteHostId val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { mutableStateOf( @@ -101,11 +100,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } val view = LocalMultiplatformView() - if (activeChat.value == null || user == null) { + val chat = activeChat.value + if (chat == null || user == null) { chatModel.chatId.value = null ModalManager.end.closeModals() } else { - val chat = activeChat.value!! + val chatRh = chat.remoteHostId // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { @@ -167,11 +167,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: var preloadedCode: String? = null var preloadedLink: Pair? = null if (chat.chatInfo is ChatInfo.Direct) { - preloadedContactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) - preloadedCode = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)?.second + preloadedContactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) + preloadedCode = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second } else if (chat.chatInfo is ChatInfo.Group) { - setGroupMembers(chat.chatInfo.groupInfo, chatModel) - preloadedLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) + setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) + preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) } ModalManager.end.showModalCloseable(true) { close -> val chat = remember { activeChat }.value @@ -179,20 +179,20 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } var code: String? by remember { mutableStateOf(preloadedCode) } KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) { - contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) + contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) preloadedContactInfo = contactInfo - code = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)?.second + code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second preloadedCode = code } ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) } else if (chat?.chatInfo is ChatInfo.Group) { var link: Pair? by remember(chat.id) { mutableStateOf(preloadedLink) } KeyChangeEffect(chat.id) { - setGroupMembers((chat.chatInfo as ChatInfo.Group).groupInfo, chatModel) - link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) + setGroupMembers(chatRh, (chat.chatInfo as ChatInfo.Group).groupInfo, chatModel) + link = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) preloadedLink = link } - GroupChatInfoView(chatModel, link?.first, link?.second, { + GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, { link = it preloadedLink = it }, close) @@ -203,19 +203,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) withApi { - val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second val (_, code) = if (member.memberActive) { - val memCode = chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) + val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) member to memCode?.second } else { member to null } - setGroupMembers(groupInfo, chatModel) + setGroupMembers(chatRh, groupInfo, chatModel) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) } } } @@ -226,7 +226,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (c != null && firstId != null) { withApi { Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}") - apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value) + apiLoadPrevMessages(c, chatModel, firstId, searchText.value) Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}") } } @@ -242,6 +242,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val toChatItem: ChatItem? if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { val r = chatModel.controller.apiDeleteMemberChatItem( + chatRh, groupId = groupInfo.groupId, groupMemberId = groupMember.groupMemberId, itemId = itemId @@ -250,6 +251,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: toChatItem = r?.second } else { val r = chatModel.controller.apiDeleteChatItem( + chatRh, type = cInfo.chatType, id = cInfo.apiId, itemId = itemId, @@ -259,9 +261,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: toChatItem = r?.toChatItem?.chatItem } if (toChatItem == null && deletedChatItem != null) { - chatModel.removeChatItem(cInfo, deletedChatItem) + chatModel.removeChatItem(chatRh, cInfo, deletedChatItem) } else if (toChatItem != null) { - chatModel.upsertChatItem(cInfo, toChatItem) + chatModel.upsertChatItem(chatRh, cInfo, toChatItem) } } }, @@ -272,27 +274,27 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val deletedItems: ArrayList = arrayListOf() for (itemId in itemIds) { val di = chatModel.controller.apiDeleteChatItem( - chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal + chatRh, chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal )?.deletedChatItem?.chatItem if (di != null) { deletedItems.add(di) } } for (di in deletedItems) { - chatModel.removeChatItem(chatInfo, di) + chatModel.removeChatItem(chatRh, chatInfo, di) } } } }, receiveFile = { fileId, encrypted -> - withApi { chatModel.controller.receiveFile(rhId, user, fileId, encrypted) } + withApi { chatModel.controller.receiveFile(chatRh, user, fileId, encrypted) } }, cancelFile = { fileId -> - withApi { chatModel.controller.cancelFile(rhId, user, fileId) } + withApi { chatModel.controller.cancelFile(chatRh, user, fileId) } }, joinGroup = { groupId, onComplete -> withApi { - chatModel.controller.apiJoinGroup(groupId) + chatModel.controller.apiJoinGroup(chatRh, groupId) onComplete.invoke() } }, @@ -300,7 +302,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: withBGApi { val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { - chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) + chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) chatModel.showCallView.value = true chatModel.callCommand.add(WCallCommand.Capabilities(media)) } @@ -321,48 +323,48 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: }, acceptFeature = { contact, feature, param -> withApi { - chatModel.controller.allowFeatureToContact(contact, feature, param) + chatModel.controller.allowFeatureToContact(chatRh, contact, feature, param) } }, openDirectChat = { contactId -> withApi { - openDirectChat(contactId, chatModel) + openDirectChat(chatRh, contactId, chatModel) } }, updateContactStats = { contact -> withApi { - val r = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) + val r = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) if (r != null) { val contactStats = r.first if (contactStats != null) - chatModel.updateContactConnectionStats(contact, contactStats) + chatModel.updateContactConnectionStats(chatRh, contact, contactStats) } } }, updateMemberStats = { groupInfo, member -> withApi { - val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) if (r != null) { val memStats = r.second if (memStats != null) { - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, memStats) + chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) } } } }, syncContactConnection = { contact -> withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } } }, syncMemberConnection = { groupInfo, member -> withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) } } }, @@ -375,6 +377,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: setReaction = { cInfo, cItem, add, reaction -> withApi { val updatedCI = chatModel.controller.apiChatItemReaction( + rh = chatRh, type = cInfo.chatType, id = cInfo.apiId, itemId = cItem.id, @@ -388,10 +391,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: }, showItemDetails = { cInfo, cItem -> withApi { - val ciInfo = chatModel.controller.apiGetChatItemInfo(cInfo.chatType, cInfo.apiId, cItem.id) + val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) if (ciInfo != null) { if (chat.chatInfo is ChatInfo.Group) { - setGroupMembers(chat.chatInfo.groupInfo, chatModel) + setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) } ModalManager.end.closeModals() ModalManager.end.showModal(endButtons = { ShareButton { @@ -405,28 +408,29 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: addMembers = { groupInfo -> hideKeyboard(view) withApi { - setGroupMembers(groupInfo, chatModel) + setGroupMembers(chatRh, groupInfo, chatModel) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, false, chatModel, close) + AddGroupMembersView(chatRh, groupInfo, false, chatModel, close) } } }, openGroupLink = { groupInfo -> hideKeyboard(view) withApi { - val link = chatModel.controller.apiGetGroupLink(groupInfo.groupId) + val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { - GroupLinkView(chatModel, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) + GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) } } }, markRead = { range, unreadCountAfter -> - chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) + chatModel.markChatItemsRead(chat, range, unreadCountAfter) ntfManager.cancelNotificationsForChat(chat.id) withBGApi { chatModel.controller.apiChatRead( + chatRh, chat.chatInfo.chatType, chat.chatInfo.apiId, range @@ -438,7 +442,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (searchText.value == value) return@ChatLayout val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout withApi { - apiFindMessages(c.chatInfo, chatModel, value) + apiFindMessages(c, chatModel, value) searchText.value = value } }, @@ -1254,14 +1258,16 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha val chat = activeChat.value if (chat?.chatStats?.unreadChat != true) return withApi { + val chatRh = chat.remoteHostId val success = chatModel.controller.apiChatUnread( + chatRh, chat.chatInfo.chatType, chat.chatInfo.apiId, false ) if (success && chat.id == activeChat.value?.id) { activeChat.value = chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)) - chatModel.replaceChat(chat.id, activeChat.value!!) + chatModel.replaceChat(chatRh, chat.id, activeChat.value!!) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index a9b7014d51..b8076b1477 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -351,9 +351,10 @@ fun ComposeView( } } - suspend fun send(rhId: Long?, cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + val cInfo = chat.chatInfo val aChatItem = chatModel.controller.apiSendMessage( - rhId = rhId, + rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, file = file, @@ -363,7 +364,7 @@ fun ComposeView( ttl = ttl ) if (aChatItem != null) { - chatModel.addChatItem(cInfo, aChatItem.chatItem) + chatModel.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) return aChatItem.chatItem } if (file != null) removeFile(file.filePath) @@ -410,23 +411,25 @@ fun ComposeView( suspend fun sendMemberContactInvitation() { val mc = checkLinkPreview() - val contact = chatModel.controller.apiSendMemberContactInvitation(chat.chatInfo.apiId, mc) + val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) if (contact != null) { - chatModel.updateContact(contact) + chatModel.updateContact(chat.remoteHostId, contact) } } - suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? { + suspend fun updateMessage(ei: ChatItem, chat: Chat, live: Boolean): ChatItem? { + val cInfo = chat.chatInfo val oldMsgContent = ei.content.msgContent if (oldMsgContent != null) { val updatedItem = chatModel.controller.apiUpdateChatItem( + rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, itemId = ei.meta.itemId, mc = updateMsgContent(oldMsgContent), live = live ) - if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + if (updatedItem != null) chatModel.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) return updatedItem?.chatItem } return null @@ -444,9 +447,9 @@ fun ComposeView( sent = null } else if (cs.contextItem is ComposeContextItem.EditingItem) { val ei = cs.contextItem.chatItem - sent = updateMessage(ei, cInfo, live) + sent = updateMessage(ei, chat, live) } else if (liveMessage != null && liveMessage.sent) { - sent = updateMessage(liveMessage.chatItem, cInfo, live) + sent = updateMessage(liveMessage.chatItem, chat, live) } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() @@ -528,7 +531,7 @@ fun ComposeView( localPath = file.filePath ) } - sent = send(remoteHost?.remoteHostId, cInfo, content, if (index == 0) quotedItemId else null, file, + sent = send(chat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, ttl = ttl ) @@ -538,7 +541,7 @@ fun ComposeView( cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { - sent = send(remoteHost?.remoteHostId, cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) + sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } clearState(live) @@ -573,7 +576,7 @@ fun ComposeView( fun allowVoiceToContact() { val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return withApi { - chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice) + chatModel.controller.allowFeatureToContact(chat.remoteHostId, contact, ChatFeature.Voice) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 465603d409..c12982ada5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -25,6 +25,7 @@ import chat.simplex.res.MR fun ContactPreferencesView( m: ChatModel, user: User, + rhId: Long?, contactId: Long, close: () -> Unit, ) { @@ -36,9 +37,9 @@ fun ContactPreferencesView( fun savePrefs(afterSave: () -> Unit = {}) { withApi { val prefs = contactFeaturesAllowedToPrefs(featuresAllowed) - val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs) + val toContact = m.controller.apiSetContactPrefs(rhId, ct.contactId, prefs) if (toContact != null) { - m.updateContact(toContact) + m.updateContact(rhId, toContact) currentFeaturesAllowed = featuresAllowed } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 90ab1b45ff..ff23d40b82 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.platform.* import chat.simplex.res.MR @Composable -fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) { +fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) { val selectedContacts = remember { mutableStateListOf() } val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) } var allowModifyMembers by remember { mutableStateOf(true) } @@ -49,16 +49,16 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch searchText, openPreferences = { ModalManager.end.showCustomModal { close -> - GroupPreferencesView(chatModel, groupInfo.id, close) + GroupPreferencesView(chatModel, rhId, groupInfo.id, close) } }, inviteMembers = { allowModifyMembers = false withApi { for (contactId in selectedContacts) { - val member = chatModel.controller.apiAddMember(groupInfo.groupId, contactId, selectedRole.value) + val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value) if (member != null) { - chatModel.upsertGroupMember(groupInfo, member) + chatModel.upsertGroupMember(rhId, groupInfo, member) } else { break } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 8c9619703b..49d76d8ecd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -41,9 +41,10 @@ import kotlinx.coroutines.launch const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 @Composable -fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit) { +fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit) { BackHandler(onBack = close) - val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + // TODO derivedStateOf? + val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } val currentUser = chatModel.currentUser.value val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { @@ -68,25 +69,25 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR groupLink, addMembers = { withApi { - setGroupMembers(groupInfo, chatModel) + setGroupMembers(rhId, groupInfo, chatModel) ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, false, chatModel, close) + AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } } }, showMemberInfo = { member -> withApi { - val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId) val stats = r?.second val (_, code) = if (member.memberActive) { - val memCode = chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) + val memCode = chatModel.controller.apiGetGroupMemberCode(rhId, groupInfo.apiId, member.groupMemberId) member to memCode?.second } else { member to null } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, stats, code, chatModel, closeCurrent) { closeCurrent() close() } @@ -95,31 +96,33 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR } }, editGroupProfile = { - ModalManager.end.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) } + ModalManager.end.showCustomModal { close -> GroupProfileView(rhId, groupInfo, chatModel, close) } }, addOrEditWelcomeMessage = { - ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) } + ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, rhId, groupInfo, close) } }, openPreferences = { ModalManager.end.showCustomModal { close -> GroupPreferencesView( chatModel, + rhId, chat.id, close ) } }, - deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) }, - clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, - leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }, + deleteGroup = { deleteGroupDialog(chat, groupInfo, chatModel, close) }, + clearChat = { clearChatDialog(chat, chatModel, close) }, + leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } + ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } } ) } } -fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo val alertTextKey = if (groupInfo.membership.memberCurrent) MR.strings.delete_group_for_all_members_cannot_undo_warning else MR.strings.delete_group_for_self_cannot_undo_warning @@ -129,9 +132,9 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) + val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId) if (r) { - chatModel.removeChat(chatInfo.id) + chatModel.removeChat(chat.remoteHostId, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -145,14 +148,14 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM ) } -fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.leave_group_question), text = generalGetString(MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved), confirmText = generalGetString(MR.strings.leave_group_button), onConfirm = { withApi { - chatModel.controller.leaveGroup(groupInfo.groupId) + chatModel.controller.leaveGroup(rhId, groupInfo.groupId) close?.invoke() } }, @@ -160,16 +163,16 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U ) } -private fun removeMemberAlert(groupInfo: GroupInfo, mem: GroupMember) { +private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.button_remove_member_question), text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withApi { - val updatedMember = chatModel.controller.apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + val updatedMember = chatModel.controller.apiRemoveMember(rhId, groupInfo.groupId, mem.groupMemberId) if (updatedMember != null) { - chatModel.upsertGroupMember(groupInfo, updatedMember) + chatModel.upsertGroupMember(rhId, groupInfo, updatedMember) } } }, @@ -260,7 +263,7 @@ fun GroupChatInfoLayout( Divider() val showMenu = remember { mutableStateOf(false) } SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp) { - DropDownMenuForMember(member, groupInfo, showMenu) + DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu) MemberRow(member, onClick = { showMemberInfo(member) }) } } @@ -413,22 +416,22 @@ private fun MemberVerifiedShield() { } @Composable -private fun DropDownMenuForMember(member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { +private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { DefaultDropdownMenu(showMenu) { if (member.canBeRemoved(groupInfo)) { ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { - removeMemberAlert(groupInfo, member) + removeMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } if (member.memberSettings.showMessages) { ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { - blockMemberAlert(groupInfo, member) + blockMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } else { ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { - unblockMemberAlert(groupInfo, member) + unblockMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 809c7c2fd6..02ce90243c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -25,6 +25,7 @@ import chat.simplex.res.MR @Composable fun GroupLinkView( chatModel: ChatModel, + rhId: Long?, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, @@ -38,7 +39,7 @@ fun GroupLinkView( fun createLink() { creatingLink = true withApi { - val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId) + val link = chatModel.controller.apiCreateGroupLink(rhId, groupInfo.groupId) if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second @@ -62,7 +63,7 @@ fun GroupLinkView( val role = groupLinkMemberRole.value if (role != null) { withBGApi { - val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role) + val link = chatModel.controller.apiGroupLinkMemberRole(rhId, groupInfo.groupId, role) if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second @@ -78,7 +79,7 @@ fun GroupLinkView( confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withApi { - val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) + val r = chatModel.controller.apiDeleteGroupLink(rhId, groupInfo.groupId) if (r) { groupLink = null onGroupLinkUpdated?.invoke(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 9f52f61deb..00b236c7dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -40,6 +40,7 @@ import kotlinx.datetime.Clock @Composable fun GroupMemberInfoView( + rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats?, @@ -49,7 +50,7 @@ fun GroupMemberInfoView( closeAll: () -> Unit, // Close all open windows up to ChatView ) { BackHandler(onBack = close) - val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId } val connStats = remember { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() var progressIndicator by remember { mutableStateOf(false) } @@ -66,7 +67,7 @@ fun GroupMemberInfoView( getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { withApi { - val c = chatModel.controller.apiGetChat(ChatType.Direct, it) + val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it) if (c != null) { if (chatModel.getContactChat(it) == null) { chatModel.addChat(c) @@ -82,9 +83,9 @@ fun GroupMemberInfoView( createMemberContact = { withApi { progressIndicator = true - val memberContact = chatModel.controller.apiCreateMemberContact(groupInfo.apiId, member.groupMemberId) + val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId) if (memberContact != null) { - val memberChat = Chat(ChatInfo.Direct(memberContact), chatItems = arrayListOf()) + val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) chatModel.addChat(memberChat) openLoadedChat(memberChat, chatModel) closeAll() @@ -94,11 +95,11 @@ fun GroupMemberInfoView( } }, connectViaAddress = { connReqUri -> - connectViaMemberAddressAlert(connReqUri) + connectViaMemberAddressAlert(rhId, connReqUri) }, - blockMember = { blockMemberAlert(groupInfo, member) }, - unblockMember = { unblockMemberAlert(groupInfo, member) }, - removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }, + blockMember = { blockMemberAlert(rhId, groupInfo, member) }, + unblockMember = { unblockMemberAlert(rhId, groupInfo, member) }, + removeMember = { removeMemberDialog(rhId, groupInfo, member, chatModel, close) }, onRoleSelected = { if (it == newRole.value) return@GroupMemberInfoLayout val prevValue = newRole.value @@ -108,8 +109,8 @@ fun GroupMemberInfoView( }) { withApi { kotlin.runCatching { - val mem = chatModel.controller.apiMemberRole(groupInfo.groupId, member.groupMemberId, it) - chatModel.upsertGroupMember(groupInfo, mem) + val mem = chatModel.controller.apiMemberRole(rhId, groupInfo.groupId, member.groupMemberId, it) + chatModel.upsertGroupMember(rhId, groupInfo, mem) }.onFailure { newRole.value = prevValue } @@ -119,10 +120,10 @@ fun GroupMemberInfoView( switchMemberAddress = { showSwitchAddressAlert(switchAddress = { withApi { - val r = chatModel.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -131,10 +132,10 @@ fun GroupMemberInfoView( abortSwitchMemberAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - val r = chatModel.controller.apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiAbortSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -142,10 +143,10 @@ fun GroupMemberInfoView( }, syncMemberConnection = { withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -153,10 +154,10 @@ fun GroupMemberInfoView( syncMemberConnectionForce = { showSyncConnectionForceAlert(syncConnectionForce = { withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = true) + val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = true) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -170,9 +171,10 @@ fun GroupMemberInfoView( connectionCode, mem.verified, verify = { code -> - chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r -> + chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r -> val (verified, existingCode) = r chatModel.upsertGroupMember( + rhId, groupInfo, mem.copy( activeConn = mem.activeConn?.copy( @@ -196,16 +198,16 @@ fun GroupMemberInfoView( } } -fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.button_remove_member), text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withApi { - val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId) + val removedMember = chatModel.controller.apiRemoveMember(rhId, member.groupId, member.groupMemberId) if (removedMember != null) { - chatModel.upsertGroupMember(groupInfo, removedMember) + chatModel.upsertGroupMember(rhId, groupInfo, removedMember) } close?.invoke() } @@ -500,11 +502,11 @@ private fun updateMemberRoleDialog( ) } -fun connectViaMemberAddressAlert(connReqUri: String) { +fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) { try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) + planAndConnect(chatModel, rhId, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -514,39 +516,39 @@ fun connectViaMemberAddressAlert(connReqUri: String) { } } -fun blockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { +fun blockMemberAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.block_member_question), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_member_confirmation), onConfirm = { - toggleShowMemberMessages(gInfo, mem, false) + toggleShowMemberMessages(rhId, gInfo, mem, false) }, destructive = true, ) } -fun unblockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { +fun unblockMemberAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.unblock_member_question), text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_member_confirmation), onConfirm = { - toggleShowMemberMessages(gInfo, mem, true) + toggleShowMemberMessages(rhId, gInfo, mem, true) }, ) } -fun toggleShowMemberMessages(gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) { +fun toggleShowMemberMessages(rhId: Long?, gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) { val updatedMemberSettings = member.memberSettings.copy(showMessages = showMessages) - updateMemberSettings(gInfo, member, updatedMemberSettings) + updateMemberSettings(rhId, gInfo, member, updatedMemberSettings) } -fun updateMemberSettings(gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) { +fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) { withBGApi { - val success = ChatController.apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings) + val success = ChatController.apiSetMemberSettings(rhId, gInfo.groupId, member.groupMemberId, memberSettings) if (success) { - ChatModel.upsertGroupMember(gInfo, member.copy(memberSettings = memberSettings)) + ChatModel.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 4571a38c13..3cdfaad2d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -21,8 +21,12 @@ import chat.simplex.common.model.* import chat.simplex.res.MR @Composable -fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) { - val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } } +fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit,) { + val groupInfo = remember { derivedStateOf { + val ch = m.getChat(chatId) + val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo + if (g == null || ch?.remoteHostId != rhId) null else g + }} val gInfo = groupInfo.value ?: return var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) } var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) } @@ -30,9 +34,9 @@ fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) { fun savePrefs(afterSave: () -> Unit = {}) { withApi { val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) - val g = m.controller.apiUpdateGroup(gInfo.groupId, gp) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) if (g != null) { - m.updateGroup(g) + m.updateGroup(rhId, g) currentPreferences = preferences } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 5376cb092b..f92fd88dc0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -30,15 +30,15 @@ import kotlinx.coroutines.launch import java.net.URI @Composable -fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { +fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { GroupProfileLayout( close = close, groupProfile = groupInfo.groupProfile, saveProfile = { p -> withApi { - val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p) + val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p) if (gInfo != null) { - chatModel.updateGroup(gInfo) + chatModel.updateGroup(rhId, gInfo) close.invoke() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 3be54376d5..577c19648d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -30,7 +30,7 @@ import chat.simplex.res.MR import kotlinx.coroutines.delay @Composable -fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) { +fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () -> Unit) { var gInfo by remember { mutableStateOf(groupInfo) } val welcomeText = remember { mutableStateOf(gInfo.groupProfile.description ?: "") } @@ -41,10 +41,10 @@ fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) { welcome = null } val groupProfileUpdated = gInfo.groupProfile.copy(description = welcome) - val res = m.controller.apiUpdateGroup(gInfo.groupId, groupProfileUpdated) + val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated) if (res != null) { gInfo = res - m.updateGroup(res) + m.updateGroup(rhId, res) welcomeText.value = welcome ?: "" } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 13380a664b..7e81faf3d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -62,7 +62,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, - click = { directChatAction(chat.chatInfo.contact, chatModel) }, + click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) }, showMenu, stopped, @@ -72,7 +72,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Group -> ChatListNavLinkLayout( chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, - click = { if (!inProgress.value) groupChatAction(chat.chatInfo.groupInfo, chatModel, inProgress) }, + click = { if (!inProgress.value) groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, showMenu, stopped, @@ -81,8 +81,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.ContactRequest -> ChatListNavLinkLayout( chatLinkPreview = { ContactRequestView(chat.chatInfo) }, - click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, - dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, + click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) }, + dropdownMenuItems = { ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, showMenu, stopped, selectedChat @@ -94,10 +94,10 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ModalManager.center.closeModals() ModalManager.end.closeModals() ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> - ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) + ContactConnectionInfoView(chatModel, chat.remoteHostId, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) } }, - dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) }, + dropdownMenuItems = { ContactConnectionMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, showMenu, stopped, selectedChat @@ -119,38 +119,38 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } } -fun directChatAction(contact: Contact, chatModel: ChatModel) { +fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { when { - contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, contact, close = null, openChat = true) - else -> withBGApi { openChat(ChatInfo.Direct(contact), chatModel) } + contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) + else -> withBGApi { openChat(rhId, ChatInfo.Direct(contact), chatModel) } } } -fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { +fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel, inProgress) + GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() - else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) } + else -> withBGApi { openChat(rhId, ChatInfo.Group(groupInfo), chatModel) } } } -suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { - val chat = chatModel.controller.apiGetChat(ChatType.Direct, contactId) +suspend fun openDirectChat(rhId: Long?, contactId: Long, chatModel: ChatModel) { + val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId) if (chat != null) { openLoadedChat(chat, chatModel) } } -suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) { - val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId) +suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) { + val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId) if (chat != null) { openLoadedChat(chat, chatModel) } } -suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { +suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) { Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) + val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId) if (chat != null) { openLoadedChat(chat, chatModel) Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") @@ -164,22 +164,24 @@ fun openLoadedChat(chat: Chat, chatModel: ChatModel) { chatModel.chatId.value = chat.chatInfo.id } -suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) { +suspend fun apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String) { + val chatInfo = ch.chatInfo val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT) - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return + val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return if (chatModel.chatId.value != chat.id) return chatModel.chatItems.addAll(0, chat.chatItems) } -suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) { - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return +suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) { + val chatInfo = ch.chatInfo + val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return if (chatModel.chatId.value != chat.id) return chatModel.chatItems.clear() chatModel.chatItems.addAll(0, chat.chatItems) } -suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { - val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId) +suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { + val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) val currentMembers = chatModel.groupMembers val newMembers = groupMembers.map { newMember -> val currentMember = currentMembers.find { it.id == newMember.id } @@ -230,7 +232,7 @@ fun GroupMenuItems( } GroupMemberStatus.MemAccepted -> { if (groupInfo.membership.memberCurrent) { - LeaveGroupAction(groupInfo, chatModel, showMenu) + LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) @@ -246,7 +248,7 @@ fun GroupMenuItems( ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) ClearChatAction(chat, chatModel, showMenu) if (groupInfo.membership.memberCurrent) { - LeaveGroupAction(groupInfo, chatModel, showMenu) + LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) @@ -310,7 +312,7 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState Unit = { withApi { inProgress.value = true - chatModel.controller.apiJoinGroup(groupInfo.groupId) + chatModel.controller.apiJoinGroup(chat.remoteHostId, groupInfo.groupId) inProgress.value = false } } @@ -370,12 +372,12 @@ fun JoinGroupAction( } @Composable -fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { +fun LeaveGroupAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(MR.strings.leave_group_button), painterResource(MR.images.ic_logout), onClick = { - leaveGroupDialog(groupInfo, chatModel) + leaveGroupDialog(rhId, groupInfo, chatModel) showMenu.value = false }, color = Color.Red @@ -383,13 +385,13 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab } @Composable -fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { +fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(MR.strings.accept_contact_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(incognito = false, chatInfo.apiId, chatInfo, true, chatModel) + acceptContactRequest(rhId, incognito = false, chatInfo.apiId, chatInfo, true, chatModel) showMenu.value = false } ) @@ -398,7 +400,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo painterResource(MR.images.ic_theater_comedy), color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(incognito = true, chatInfo.apiId, chatInfo, true, chatModel) + acceptContactRequest(rhId, incognito = true, chatInfo.apiId, chatInfo, true, chatModel) showMenu.value = false } ) @@ -406,7 +408,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo stringResource(MR.strings.reject_contact_button), painterResource(MR.images.ic_close), onClick = { - rejectContactRequest(chatInfo, chatModel) + rejectContactRequest(rhId, chatInfo, chatModel) showMenu.value = false }, color = Color.Red @@ -414,7 +416,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo } @Composable -fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState) { +fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(MR.strings.set_contact_name), painterResource(MR.images.ic_edit), @@ -422,7 +424,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ModalManager.center.closeModals() ModalManager.end.closeModals() ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> - ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close) + ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close) } showMenu.value = false }, @@ -431,7 +433,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), onClick = { - deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) { + deleteContactConnectionAlert(rhId, chatInfo.contactConnection, chatModel) { if (chatModel.chatId.value == null) { ModalManager.center.closeModals() ModalManager.end.closeModals() @@ -471,8 +473,9 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { withApi { if (chat.chatStats.unreadCount > 0) { val minUnreadItemId = chat.chatStats.minUnreadItemId - chatModel.markChatItemsRead(chat.chatInfo) + chatModel.markChatItemsRead(chat) chatModel.controller.apiChatRead( + chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, CC.ItemRange(minUnreadItemId, chat.chatItems.last().id) @@ -481,12 +484,13 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { } if (chat.chatStats.unreadChat) { val success = chatModel.controller.apiChatUnread( + chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, false ) if (success) { - chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) } } } @@ -498,17 +502,18 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { withApi { val success = chatModel.controller.apiChatUnread( + chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, true ) if (success) { - chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) } } } -fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { +fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_connection_request__question), text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)), @@ -516,19 +521,19 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel Column { SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(incognito = false, contactRequest.apiId, contactRequest, true, chatModel) + acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel) }) { Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(incognito = true, contactRequest.apiId, contactRequest, true, chatModel) + acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel) }) { Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() - rejectContactRequest(contactRequest, chatModel) + rejectContactRequest(rhId, contactRequest, chatModel) }) { Text(generalGetString(MR.strings.reject_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) } @@ -537,24 +542,24 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel ) } -fun acceptContactRequest(incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) { +fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) { withApi { - val contact = chatModel.controller.apiAcceptContactRequest(incognito, apiId) + val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, apiId) if (contact != null && isCurrentUser && contactRequest != null) { - val chat = Chat(ChatInfo.Direct(contact), listOf()) - chatModel.replaceChat(contactRequest.id, chat) + val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) + chatModel.replaceChat(rhId, contactRequest.id, chat) } } } -fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { +fun rejectContactRequest(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { withApi { - chatModel.controller.apiRejectContactRequest(contactRequest.apiId) - chatModel.removeChat(contactRequest.id) + chatModel.controller.apiRejectContactRequest(rhId, contactRequest.apiId) + chatModel.removeChat(rhId, contactRequest.id) } } -fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) { +fun deleteContactConnectionAlert(rhId: Long?, connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_pending_connection__question), text = generalGetString( @@ -565,8 +570,8 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel onConfirm = { withApi { AlertManager.shared.hideAlert() - if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) { - chatModel.removeChat(connection.id) + if (chatModel.controller.apiDeleteChat(rhId, ChatType.ContactConnection, connection.apiId)) { + chatModel.removeChat(rhId, connection.id) onSuccess() } } @@ -575,16 +580,17 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel ) } -fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { +// TODO why is it not used +fun pendingContactAlertDialog(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.alert_title_contact_connection_pending), text = generalGetString(MR.strings.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry), confirmText = generalGetString(MR.strings.button_delete_contact), onConfirm = { withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) + val r = chatModel.controller.apiDeleteChat(rhId, chatInfo.chatType, chatInfo.apiId) if (r) { - chatModel.removeChat(chatInfo.id) + chatModel.removeChat(rhId, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -599,6 +605,7 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { fun askCurrentOrIncognitoProfileConnectContactViaAddress( chatModel: ChatModel, + rhId: Long?, contact: Contact, close: (() -> Unit)?, openChat: Boolean @@ -611,9 +618,9 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( AlertManager.shared.hideAlert() withApi { close?.invoke() - val ok = connectContactViaAddress(chatModel, contact.contactId, incognito = false) + val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false) if (ok && openChat) { - openDirectChat(contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId, chatModel) } } }) { @@ -623,9 +630,9 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( AlertManager.shared.hideAlert() withApi { close?.invoke() - val ok = connectContactViaAddress(chatModel, contact.contactId, incognito = true) + val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true) if (ok && openChat) { - openDirectChat(contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId, chatModel) } } }) { @@ -641,10 +648,10 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( ) } -suspend fun connectContactViaAddress(chatModel: ChatModel, contactId: Long, incognito: Boolean): Boolean { - val contact = chatModel.controller.apiConnectContactViaAddress(incognito, contactId) +suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactId: Long, incognito: Boolean): Boolean { + val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId) if (contact != null) { - chatModel.updateContact(contact) + chatModel.updateContact(rhId, contact) AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) @@ -654,7 +661,7 @@ suspend fun connectContactViaAddress(chatModel: ChatModel, contactId: Long, inco return false } -fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { +fun acceptGroupInvitationAlertDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.join_group_question), text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members), @@ -662,12 +669,12 @@ fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, onConfirm = { withApi { inProgress?.value = true - chatModel.controller.apiJoinGroup(groupInfo.groupId) + chatModel.controller.apiJoinGroup(rhId, groupInfo.groupId) inProgress?.value = false } }, dismissText = generalGetString(MR.strings.delete_verb), - onDismiss = { deleteGroup(groupInfo, chatModel) } + onDismiss = { deleteGroup(rhId, groupInfo, chatModel) } ) } @@ -679,11 +686,11 @@ fun cantInviteIncognitoAlert() { ) } -fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) { +fun deleteGroup(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { withApi { - val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId) + val r = chatModel.controller.apiDeleteChat(rhId, ChatType.Group, groupInfo.apiId) if (r) { - chatModel.removeChat(groupInfo.id) + chatModel.removeChat(rhId, groupInfo.id) if (chatModel.chatId.value == groupInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -723,15 +730,15 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo withApi { val res = when (newChatInfo) { is ChatInfo.Direct -> with(newChatInfo) { - chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings) + chatModel.controller.apiSetSettings(chat.remoteHostId, chatType, apiId, contact.chatSettings) } is ChatInfo.Group -> with(newChatInfo) { - chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings) + chatModel.controller.apiSetSettings(chat.remoteHostId, chatType, apiId, groupInfo.chatSettings) } else -> false } if (res && newChatInfo != null) { - chatModel.updateChatInfo(newChatInfo) + chatModel.updateChatInfo(chat.remoteHostId, newChatInfo) if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chat.id) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 7883a7a738..1644d286f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -53,7 +53,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val url = chatModel.appOpenUrl.value if (url != null) { chatModel.appOpenUrl.value = null - connectIfOpenedViaUri(url, chatModel) + connectIfOpenedViaUri(chatModel.remoteHostId, url, chatModel) } } if (appPlatform.isDesktop) { @@ -117,7 +117,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } if (searchInList.isEmpty()) { DesktopActiveCallOverlayLayout(newChatSheetState) - NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) + // TODO disable this button and sheet for the duration of the switch + NewChatSheet(chatModel, chatModel.remoteHostId, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { @@ -317,13 +318,13 @@ private fun ProgressIndicator() { @Composable expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) -fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { +fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { chatModel.appOpenUrl.value = uri } else { withApi { - planAndConnect(chatModel, uri, incognito = null, close = null) + planAndConnect(chatModel, rhId, uri, incognito = null, close = null) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index e423a591da..ad8f93990f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -20,13 +20,13 @@ fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Direct -> ShareListNavLinkLayout( chatLinkPreview = { SharePreviewView(chat) }, - click = { directChatAction(chat.chatInfo.contact, chatModel) }, + click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) }, stopped ) is ChatInfo.Group -> ShareListNavLinkLayout( chatLinkPreview = { SharePreviewView(chat) }, - click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, + click = { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel) }, stopped ) is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index caf8ec5cb0..20e856df6e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -84,7 +84,7 @@ fun UserPicker( .filter { it } .collect { try { - val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser } + val updatedUsers = chatModel.controller.listUsers(chatModel.remoteHostId).sortedByDescending { it.user.activeUser } var same = users.size == updatedUsers.size if (same) { for (i in 0 until minOf(users.size, updatedUsers.size)) { @@ -129,7 +129,7 @@ fun UserPicker( switchingUsersAndHosts.value = true } ModalManager.closeAllModalsEverywhere() - chatModel.controller.changeActiveUser(u.user.userId, null) + chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null) job.cancel() switchingUsersAndHosts.value = false } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 0cca354746..1f4be29664 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -65,6 +65,8 @@ fun DatabaseView( Box( Modifier.fillMaxSize(), ) { + val user = m.currentUser.value + val rhId = user?.remoteHostId DatabaseLayout( progressIndicator.value, remember { m.chatRunning }.value != false, @@ -80,7 +82,7 @@ fun DatabaseView( chatLastStart, appFilesCountAndSize, chatItemTTL, - m.currentUser.value, + user, m.users, startChat = { startChat(m, chatLastStart, m.chatDbChanged) }, stopChatAlert = { stopChatAlert(m) }, @@ -91,9 +93,9 @@ fun DatabaseView( val oldValue = chatItemTTL.value chatItemTTL.value = it if (it < oldValue) { - setChatItemTTLAlert(m, chatItemTTL, progressIndicator, appFilesCountAndSize) + setChatItemTTLAlert(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } else if (it != oldValue) { - setCiTTL(m, chatItemTTL, progressIndicator, appFilesCountAndSize) + setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } }, showSettingsModal @@ -265,7 +267,7 @@ fun DatabaseLayout( } private fun setChatItemTTLAlert( - m: ChatModel, selectedChatItemTTL: MutableState, + m: ChatModel, rhId: Long?, selectedChatItemTTL: MutableState, progressIndicator: MutableState, appFilesCountAndSize: MutableState>, ) { @@ -273,7 +275,7 @@ private fun setChatItemTTLAlert( title = generalGetString(MR.strings.enable_automatic_deletion_question), text = generalGetString(MR.strings.enable_automatic_deletion_message), confirmText = generalGetString(MR.strings.delete_messages), - onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize) }, + onConfirm = { setCiTTL(m, rhId, selectedChatItemTTL, progressIndicator, appFilesCountAndSize) }, onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value }, destructive = true, ) @@ -592,6 +594,7 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState) { private fun setCiTTL( m: ChatModel, + rhId: Long?, chatItemTTL: MutableState, progressIndicator: MutableState, appFilesCountAndSize: MutableState>, @@ -600,7 +603,7 @@ private fun setCiTTL( progressIndicator.value = true withApi { try { - m.controller.setChatItemTTL(chatItemTTL.value) + m.controller.setChatItemTTL(rhId, chatItemTTL.value) // Update model on success m.chatItemTTL.value = chatItemTTL.value afterSetCiTTL(m, progressIndicator, appFilesCountAndSize) @@ -623,7 +626,8 @@ private fun afterSetCiTTL( withApi { try { updatingChatsMutex.withLock { - val chats = m.controller.apiGetChats() + // this is using current remote host on purpose - if it changes during update, it will load correct chats + val chats = m.controller.apiGetChats(m.remoteHostId) m.updateChats(chats) } } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 4ed5ea56b4..dbadec32f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -373,7 +373,7 @@ inline fun serializableSaver(): Saver = Saver( fun UriHandler.openVerifiedSimplexUri(uri: String) { val URI = try { URI.create(uri) } catch (e: Exception) { null } if (URI != null) { - connectIfOpenedViaUri(URI, ChatModel) + connectIfOpenedViaUri(chatModel.remoteHostId, URI, ChatModel) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 8b5c2a8336..c64c3dd29a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -63,7 +63,7 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( if (!displayName.isNullOrEmpty()) { profile = Profile(displayName = displayName, fullName = "") } - val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true) + val createdUser = m.controller.apiCreateActiveUser(null, profile, pastTimestamp = true) m.currentUser.value = createdUser m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) if (createdUser != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index ef3633d1fa..360667fcfb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -25,11 +25,13 @@ import chat.simplex.res.MR @Composable fun AddContactView( chatModel: ChatModel, + rhId: Long?, connReqInvitation: String, contactConnection: MutableState ) { val clipboard = LocalClipboardManager.current AddContactLayout( + rhId = rhId, chatModel = chatModel, incognitoPref = chatModel.controller.appPrefs.incognito, connReq = connReqInvitation, @@ -52,6 +54,7 @@ fun AddContactView( @Composable fun AddContactLayout( chatModel: ChatModel, + rhId: Long?, incognitoPref: SharedPreference, connReq: String, contactConnection: MutableState, @@ -63,9 +66,9 @@ fun AddContactLayout( withApi { val contactConnVal = contactConnection.value if (contactConnVal != null) { - chatModel.controller.apiSetConnectionIncognito(contactConnVal.pccConnId, incognito.value)?.let { + chatModel.controller.apiSetConnectionIncognito(rhId, contactConnVal.pccConnId, incognito.value)?.let { contactConnection.value = it - chatModel.updateContactConnection(it) + chatModel.updateContactConnection(rhId, it) } } } @@ -172,6 +175,7 @@ fun sharedProfileInfo( fun PreviewAddContactView() { SimpleXTheme { AddContactLayout( + rhId = null, chatModel = ChatModel, incognitoPref = SharedPreference({ false }, {}), connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 9b2cedefaa..d60ee75311 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -32,25 +32,25 @@ import kotlinx.coroutines.launch import java.net.URI @Composable -fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { +fun AddGroupView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { AddGroupLayout( createGroup = { incognito, groupProfile -> withApi { - val groupInfo = chatModel.controller.apiNewGroup(incognito, groupProfile) + val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile) if (groupInfo != null) { - chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) + chatModel.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() chatModel.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id - setGroupMembers(groupInfo, chatModel) + setGroupMembers(rhId, groupInfo, chatModel) close.invoke() if (!groupInfo.incognito) { ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, creatingGroup = true, chatModel, close) + AddGroupMembersView(rhId, groupInfo, creatingGroup = true, chatModel, close) } } else { ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) + GroupLinkView(chatModel, rhId, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt index a2bc2c4df4..e41c8701e3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt @@ -8,4 +8,4 @@ enum class ConnectViaLinkTab { } @Composable -expect fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) +expect fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index d04a85d905..50419cdaff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -30,6 +30,7 @@ import chat.simplex.res.MR @Composable fun ContactConnectionInfoView( chatModel: ChatModel, + rhId: Long?, connReqInvitation: String?, contactConnection: PendingContactConnection, focusAlias: Boolean, @@ -55,8 +56,8 @@ fun ContactConnectionInfoView( connReq = connReqInvitation, contactConnection = contactConnection, focusAlias = focusAlias, - deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) }, - onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) }, + deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, + onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.center.showModal { @@ -165,9 +166,9 @@ fun DeleteButton(onClick: () -> Unit) { ) } -private fun setContactAlias(contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withApi { - chatModel.controller.apiSetConnectionAlias(contactConnection.pccConnId, localAlias)?.let { - chatModel.updateContactConnection(it) +private fun setContactAlias(rhId: Long?, contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withApi { + chatModel.controller.apiSetConnectionAlias(rhId, contactConnection.pccConnId, localAlias)?.let { + chatModel.updateContactConnection(rhId, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt index 94538655b9..f4252f53b0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt @@ -20,7 +20,7 @@ enum class CreateLinkTab { } @Composable -fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { +fun CreateLinkView(m: ChatModel, rhId: Long?, initialSelection: CreateLinkTab) { val selection = remember { mutableStateOf(initialSelection) } val connReqInvitation = rememberSaveable { m.connReqInv } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) } @@ -32,7 +32,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { && contactConnection.value == null && !creatingConnReq.value ) { - createInvitation(m, creatingConnReq, connReqInvitation, contactConnection) + createInvitation(m, rhId, creatingConnReq, connReqInvitation, contactConnection) } } /** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. @@ -65,10 +65,10 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { Column(Modifier.weight(1f)) { when (selection.value) { CreateLinkTab.ONE_TIME -> { - AddContactView(m, connReqInvitation.value ?: "", contactConnection) + AddContactView(m, rhId,connReqInvitation.value ?: "", contactConnection) } CreateLinkTab.LONG_TERM -> { - UserAddressView(m, viaCreateLinkView = true, close = {}) + UserAddressView(m, rhId, viaCreateLinkView = true, close = {}) } } } @@ -100,13 +100,14 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { private fun createInvitation( m: ChatModel, + rhId: Long?, creatingConnReq: MutableState, connReqInvitation: MutableState, contactConnection: MutableState ) { creatingConnReq.value = true withApi { - val r = m.controller.apiAddContact(incognito = m.controller.appPrefs.incognito.get()) + val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get()) if (r != null) { connReqInvitation.value = r.first contactConnection.value = r.second diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 8ec54e344f..382bc72e4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -33,7 +33,8 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt @Composable -fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { +fun NewChatSheet(chatModel: ChatModel, rhId: Long?, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { + // TODO close new chat if remote host changes in model if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } NewChatSheetLayout( newChatSheetState, @@ -41,17 +42,17 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow ConnectViaLinkView(chatModel, close) } + ModalManager.center.showModalCloseable { close -> ConnectViaLinkView(chatModel, rhId, close) } }, createGroup = { closeNewChatSheet(false) ModalManager.center.closeModals() - ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, close) } + ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, rhId, close) } }, closeNewChatSheet, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index b142b8e16a..d40fa97624 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -24,11 +24,12 @@ import chat.simplex.res.MR import java.net.URI @Composable -fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { +fun PasteToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { val connectionLink = remember { mutableStateOf("") } val clipboard = LocalClipboardManager.current PasteToConnectLayout( chatModel = chatModel, + rhId = rhId, incognitoPref = chatModel.controller.appPrefs.incognito, connectionLink = connectionLink, pasteFromClipboard = { @@ -41,6 +42,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { @Composable fun PasteToConnectLayout( chatModel: ChatModel, + rhId: Long?, incognitoPref: SharedPreference, connectionLink: MutableState, pasteFromClipboard: () -> Unit, @@ -52,7 +54,7 @@ fun PasteToConnectLayout( try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, uri, incognito = incognito.value, close) + planAndConnect(chatModel, rhId, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -124,6 +126,7 @@ fun PreviewPasteToConnectTextbox() { SimpleXTheme { PasteToConnectLayout( chatModel = ChatModel, + rhId = null, incognitoPref = SharedPreference({ false }, {}), connectionLink = remember { mutableStateOf("") }, pasteFromClipboard = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 7629256fc3..523b7e5327 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -26,7 +26,7 @@ import chat.simplex.res.MR import java.net.URI @Composable -expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) +expect fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) enum class ConnectionLinkType { INVITATION, CONTACT, GROUP @@ -34,21 +34,22 @@ enum class ConnectionLinkType { suspend fun planAndConnect( chatModel: ChatModel, + rhId: Long?, uri: URI, incognito: Boolean?, close: (() -> Unit)? ) { - val connectionPlan = chatModel.controller.apiConnectPlan(uri.toString()) + val connectionPlan = chatModel.controller.apiConnectPlan(rhId, uri.toString()) if (connectionPlan != null) { when (connectionPlan) { is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) { InvitationLinkPlan.Ok -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_invitation_link), text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), connectDestructive = false @@ -62,12 +63,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link)), connectDestructive = true @@ -78,7 +79,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .InvitationLink, .Connecting, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact_ if (contact != null) { - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) @@ -93,7 +94,7 @@ suspend fun planAndConnect( is InvitationLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) @@ -104,10 +105,10 @@ suspend fun planAndConnect( ContactAddressPlan.Ok -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_contact_link), text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), connectDestructive = false @@ -121,12 +122,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address)), connectDestructive = true @@ -140,12 +141,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address)), connectDestructive = true @@ -155,7 +156,7 @@ suspend fun planAndConnect( is ContactAddressPlan.ConnectingProhibit -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) @@ -164,7 +165,7 @@ suspend fun planAndConnect( is ContactAddressPlan.Known -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) @@ -175,9 +176,9 @@ suspend fun planAndConnect( val contact = connectionPlan.contactAddressPlan.contact if (incognito != null) { close?.invoke() - connectContactViaAddress(chatModel, contact.contactId, incognito) + connectContactViaAddress(chatModel, rhId, contact.contactId, incognito) } else { - askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, contact, close, openChat = false) + askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close, openChat = false) } } } @@ -189,11 +190,11 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } } + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } } ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_group_link), text = AnnotatedString(generalGetString(MR.strings.you_will_join_group)), connectDestructive = false @@ -203,7 +204,7 @@ suspend fun planAndConnect( is GroupLinkPlan.OwnLink -> { Log.d(TAG, "planAndConnect, .GroupLink, .OwnLink, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo - ownGroupLinkConfirmConnect(chatModel, uri, incognito, connectionPlan, groupInfo, close) + ownGroupLinkConfirmConnect(chatModel, rhId, uri, incognito, connectionPlan, groupInfo, close) } GroupLinkPlan.ConnectingConfirmReconnect -> { Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") @@ -212,12 +213,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link)), connectDestructive = true @@ -242,7 +243,7 @@ suspend fun planAndConnect( is GroupLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo - openKnownGroup(chatModel, close, groupInfo) + openKnownGroup(chatModel, rhId, close, groupInfo) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName) @@ -253,10 +254,10 @@ suspend fun planAndConnect( } else { Log.d(TAG, "planAndConnect, plan error") if (incognito != null) { - connectViaUri(chatModel, uri, incognito, connectionPlan = null, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan = null, close) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan = null, close, + chatModel, rhId, uri, connectionPlan = null, close, title = generalGetString(MR.strings.connect_plan_connect_via_link), connectDestructive = false ) @@ -266,12 +267,13 @@ suspend fun planAndConnect( suspend fun connectViaUri( chatModel: ChatModel, + rhId: Long?, uri: URI, incognito: Boolean, connectionPlan: ConnectionPlan?, close: (() -> Unit)? ): Boolean { - val r = chatModel.controller.apiConnect(incognito, uri.toString()) + val r = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION if (r) { close?.invoke() @@ -298,6 +300,7 @@ fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType fun askCurrentOrIncognitoProfileAlert( chatModel: ChatModel, + rhId: Long?, uri: URI, connectionPlan: ConnectionPlan?, close: (() -> Unit)?, @@ -314,7 +317,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -322,7 +325,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -337,18 +340,19 @@ fun askCurrentOrIncognitoProfileAlert( ) } -fun openKnownContact(chatModel: ChatModel, close: (() -> Unit)?, contact: Contact) { +fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact) { withApi { val c = chatModel.getContactChat(contact.contactId) if (c != null) { close?.invoke() - openDirectChat(contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId, chatModel) } } } fun ownGroupLinkConfirmConnect( chatModel: ChatModel, + rhId: Long?, uri: URI, incognito: Boolean?, connectionPlan: ConnectionPlan?, @@ -363,7 +367,7 @@ fun ownGroupLinkConfirmConnect( // Open group SectionItemView({ AlertManager.shared.hideAlert() - openKnownGroup(chatModel, close, groupInfo) + openKnownGroup(chatModel, rhId, close, groupInfo) }) { Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -372,7 +376,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }) { Text( @@ -385,7 +389,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -394,7 +398,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -411,12 +415,12 @@ fun ownGroupLinkConfirmConnect( ) } -fun openKnownGroup(chatModel: ChatModel, close: (() -> Unit)?, groupInfo: GroupInfo) { +fun openKnownGroup(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) { withApi { val g = chatModel.getGroupChat(groupInfo.groupId) if (g != null) { close?.invoke() - openGroupChat(groupInfo.groupId, chatModel) + openGroupChat(rhId, groupInfo.groupId, chatModel) } } } @@ -424,6 +428,7 @@ fun openKnownGroup(chatModel: ChatModel, close: (() -> Unit)?, groupInfo: GroupI @Composable fun ConnectContactLayout( chatModel: ChatModel, + rhId: Long?, incognitoPref: SharedPreference, close: () -> Unit ) { @@ -435,7 +440,7 @@ fun ConnectContactLayout( try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, uri, incognito = incognito.value, close) + planAndConnect(chatModel, rhId, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -487,6 +492,7 @@ fun PreviewConnectContactLayout() { SimpleXTheme { ConnectContactLayout( chatModel = ChatModel, + rhId = null, incognitoPref = SharedPreference({ false }, {}), close = {}, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 0132223383..49e62fb067 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -23,14 +23,14 @@ import chat.simplex.common.views.newchat.simplexChatLink import chat.simplex.res.MR @Composable -fun CreateSimpleXAddress(m: ChatModel) { +fun CreateSimpleXAddress(m: ChatModel, rhId: Long?) { var progressIndicator by remember { mutableStateOf(false) } val userAddress = remember { m.userAddress } val clipboard = LocalClipboardManager.current val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { - prepareChatBeforeAddressCreation() + prepareChatBeforeAddressCreation(rhId) } CreateSimpleXAddressLayout( @@ -45,11 +45,11 @@ fun CreateSimpleXAddress(m: ChatModel) { createAddress = { withApi { progressIndicator = true - val connReqContact = m.controller.apiCreateUserAddress() + val connReqContact = m.controller.apiCreateUserAddress(rhId) if (connReqContact != null) { m.userAddress.value = UserContactLinkRec(connReqContact) try { - val u = m.controller.apiSetProfileAddress(true) + val u = m.controller.apiSetProfileAddress(rhId, true) if (u != null) { m.updateUser(u) } @@ -176,18 +176,18 @@ private fun ProgressIndicator() { } } -private fun prepareChatBeforeAddressCreation() { +private fun prepareChatBeforeAddressCreation(rhId: Long?) { if (chatModel.users.isNotEmpty()) return withApi { - val user = chatModel.controller.apiGetActiveUser() ?: return@withApi + val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) } else { - val users = chatModel.controller.listUsers() + val users = chatModel.controller.listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - chatModel.controller.getUserChatData() + chatModel.controller.getUserChatData(rhId) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 9bc5ae846e..a4ebd23de4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -80,7 +80,7 @@ fun SetupDatabasePassphrase(m: ChatModel) { onDispose { if (m.chatRunning.value != true) { withBGApi { - val user = chatController.apiGetActiveUser() + val user = chatController.apiGetActiveUser(null) if (user != null) { m.controller.startChat(user) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index 215899d0ff..f3496c850f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -34,7 +34,7 @@ fun HiddenProfileView( saveProfilePassword = { hidePassword -> withBGApi { try { - val u = m.controller.apiHideUser(user.userId, hidePassword) + val u = m.controller.apiHideUser(user, hidePassword) m.updateUser(u) close() } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index f2fee926a7..9ee1b39384 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -168,9 +168,9 @@ fun NetworkAndServersView( ) { AppBarTitle(stringResource(MR.strings.network_and_servers)) SectionView(generalGetString(MR.strings.settings_section_title_messages)) { - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, ServerProtocol.SMP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) }) - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, ServerProtocol.XFTP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) }) UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index 202602b287..e94c53f644 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -25,11 +25,11 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { fun savePrefs(afterSave: () -> Unit = {}) { withApi { val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences()) - val updated = m.controller.apiUpdateProfile(newProfile) + val updated = m.controller.apiUpdateProfile(user.remoteHostId, newProfile) if (updated != null) { val (updatedProfile, updatedContacts) = updated - m.updateCurrentUser(updatedProfile, preferences) - updatedContacts.forEach(m::updateContact) + m.updateCurrentUser(user.remoteHostId, updatedProfile, preferences) + updatedContacts.forEach { m.updateContact(user.remoteHostId, it) } currentPreferences = preferences } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 84ab87c653..1a5aa49eb0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -99,7 +99,7 @@ fun PrivacySettingsView( fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) { withApi { val mrs = UserMsgReceiptSettings(enable, clearOverrides) - chatModel.controller.apiSetUserContactReceipts(currentUser.userId, mrs) + chatModel.controller.apiSetUserContactReceipts(currentUser, mrs) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = currentUser.copy(sendRcptsContacts = enable) if (clearOverrides) { @@ -111,7 +111,7 @@ fun PrivacySettingsView( val sendRcpts = contact.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null)) - chatModel.updateContact(contact) + chatModel.updateContact(currentUser.remoteHostId, contact) } } } @@ -122,7 +122,7 @@ fun PrivacySettingsView( fun setSendReceiptsGroups(enable: Boolean, clearOverrides: Boolean) { withApi { val mrs = UserMsgReceiptSettings(enable, clearOverrides) - chatModel.controller.apiSetUserGroupReceipts(currentUser.userId, mrs) + chatModel.controller.apiSetUserGroupReceipts(currentUser, mrs) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = currentUser.copy(sendRcptsSmallGroups = enable) if (clearOverrides) { @@ -134,7 +134,7 @@ fun PrivacySettingsView( val sendRcpts = groupInfo.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null)) - chatModel.updateGroup(groupInfo) + chatModel.updateGroup(currentUser.remoteHostId, groupInfo) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt index f3896a3de9..4e8da36a7e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt @@ -197,7 +197,7 @@ fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) = suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair = try { - val r = m.controller.testProtoServer(server.server) + val r = m.controller.testProtoServer(server.remoteHostId, server.server) server.copy(tested = r == null) to r } catch (e: Exception) { Log.e(TAG, "testServerConnection ${e.stackTraceToString()}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt index 92246b72fb..cbcb7344f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt @@ -28,7 +28,8 @@ import chat.simplex.res.MR import kotlinx.coroutines.launch @Composable -fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () -> Unit) { +fun ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) { + // TODO close if remote host changes var presetServers by remember { mutableStateOf(emptyList()) } var servers by remember { mutableStateOf(m.userSMPServersUnsaved.value ?: emptyList()) @@ -51,7 +52,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () } LaunchedEffect(Unit) { - val res = m.controller.getUserProtoServers(serverProtocol) + val res = m.controller.getUserProtoServers(rhId, serverProtocol) if (res != null) { currServers.value = res.protoServers presetServers = res.presetServers @@ -90,7 +91,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () ModalView( close = { if (saveDisabled.value) close() - else showUnsavedChangesAlert({ saveServers(serverProtocol, currServers, servers, m, close) }, close) + else showUnsavedChangesAlert({ saveServers(rhId, serverProtocol, currServers, servers, m, close) }, close) }, ) { ProtocolServersLayout( @@ -118,7 +119,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () SectionItemView({ AlertManager.shared.hideAlert() ModalManager.start.showModalCloseable { close -> - ScanProtocolServer { + ScanProtocolServer(rhId) { close() servers = servers + it m.userSMPServersUnsaved.value = servers @@ -133,7 +134,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () if (!hasAllPresets) { SectionItemView({ AlertManager.shared.hideAlert() - servers = (servers + addAllPresets(presetServers, servers, m)).sortedByDescending { it.preset } + servers = (servers + addAllPresets(rhId, presetServers, servers, m)).sortedByDescending { it.preset } }) { Text(stringResource(MR.strings.smp_servers_preset_add), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -155,7 +156,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () m.userSMPServersUnsaved.value = null }, saveSMPServers = { - saveServers(serverProtocol, currServers, servers, m) + saveServers(rhId, serverProtocol, currServers, servers, m) }, showServer = ::showServer, ) @@ -289,11 +290,11 @@ private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List, servers: List, m: ChatModel): Boolean = presetServers.all { hasPreset(it, servers) } ?: true -private fun addAllPresets(presetServers: List, servers: List, m: ChatModel): List { +private fun addAllPresets(rhId: Long?, presetServers: List, servers: List, m: ChatModel): List { val toAdd = ArrayList() for (srv in presetServers) { if (!hasPreset(srv, servers)) { - toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true)) + toAdd.add(ServerCfg(remoteHostId = rhId, srv, preset = true, tested = null, enabled = true)) } } return toAdd @@ -346,9 +347,9 @@ private suspend fun runServersTest(servers: List, m: ChatModel, onUpd return fs } -private fun saveServers(protocol: ServerProtocol, currServers: MutableState>, servers: List, m: ChatModel, afterSave: () -> Unit = {}) { +private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: MutableState>, servers: List, m: ChatModel, afterSave: () -> Unit = {}) { withApi { - if (m.controller.setUserProtoServers(protocol, servers)) { + if (m.controller.setUserProtoServers(rhId, protocol, servers)) { currServers.value = servers m.userSMPServersUnsaved.value = null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt index 02582ec93c..ac74bd04d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt @@ -13,10 +13,10 @@ import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.res.MR @Composable -expect fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) +expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) @Composable -fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { +fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) { Column( Modifier .fillMaxSize() @@ -32,7 +32,7 @@ fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { QRCodeScanner { text -> val res = parseServerAddress(text) if (res != null) { - onNext(ServerCfg(text, false, null, true)) + onNext(ServerCfg(remoteHostId = rhId, text, false, null, true)) } else { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.smp_servers_invalid_address), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index 089ec7713f..b75f522686 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -26,12 +26,12 @@ fun SetDeliveryReceiptsView(m: ChatModel) { if (currentUser != null) { withApi { try { - m.controller.apiSetAllContactReceipts(enable = true) + m.controller.apiSetAllContactReceipts(currentUser.remoteHostId, enable = true) m.currentUser.value = currentUser.copy(sendRcptsContacts = true) m.setDeliveryReceipts.value = false m.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) try { - val users = m.controller.listUsers() + val users = m.controller.listUsers(currentUser.remoteHostId) m.users.clear() m.users.addAll(users) } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 2d4e5c86b9..7bd060e8d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -155,7 +155,7 @@ fun SettingsLayout( } val profileHidden = rememberSaveable { mutableStateOf(false) } SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, it.currentUser.value?.remoteHostId, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) ChatPreferencesItem(showCustomModal, stopped = stopped) if (appPlatform.isDesktop) { SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView(it) }, disabled = stopped, extraPadding = true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index d03b758565..98989a775a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -33,10 +33,12 @@ import chat.simplex.res.MR @Composable fun UserAddressView( chatModel: ChatModel, + rhId: Long?, viaCreateLinkView: Boolean = false, shareViaProfile: Boolean = false, close: () -> Unit ) { + // TODO close when remote host changes val shareViaProfile = remember { mutableStateOf(shareViaProfile) } var progressIndicator by remember { mutableStateOf(false) } val onCloseHandler: MutableState<(close: () -> Unit) -> Unit> = remember { mutableStateOf({ _ -> }) } @@ -45,7 +47,7 @@ fun UserAddressView( progressIndicator = true withBGApi { try { - val u = chatModel.controller.apiSetProfileAddress(on) + val u = chatModel.controller.apiSetProfileAddress(rhId, on) if (u != null) { chatModel.updateUser(u) } @@ -67,7 +69,7 @@ fun UserAddressView( createAddress = { withApi { progressIndicator = true - val connReqContact = chatModel.controller.apiCreateUserAddress() + val connReqContact = chatModel.controller.apiCreateUserAddress(rhId) if (connReqContact != null) { chatModel.userAddress.value = UserContactLinkRec(connReqContact) @@ -112,7 +114,7 @@ fun UserAddressView( onConfirm = { progressIndicator = true withApi { - val u = chatModel.controller.apiDeleteUserAddress() + val u = chatModel.controller.apiDeleteUserAddress(rhId) if (u != null) { chatModel.userAddress.value = null chatModel.updateUser(u) @@ -126,7 +128,7 @@ fun UserAddressView( }, saveAas = { aas: AutoAcceptState, savedAAS: MutableState -> withBGApi { - val address = chatModel.controller.userAddressAutoAccept(aas.autoAccept) + val address = chatModel.controller.userAddressAutoAccept(rhId, aas.autoAccept) if (address != null) { chatModel.userAddress.value = address savedAAS.value = aas diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index ea4ef79d42..4bc2d92410 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -37,10 +37,10 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { close, saveProfile = { displayName, fullName, image -> withApi { - val updated = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName.trim(), fullName = fullName, image = image)) + val updated = chatModel.controller.apiUpdateProfile(user.remoteHostId, profile.copy(displayName = displayName.trim(), fullName = fullName, image = image)) if (updated != null) { val (newProfile, _) = updated - chatModel.updateCurrentUser(newProfile) + chatModel.updateCurrentUser(user.remoteHostId, newProfile) profile = newProfile close() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index 7d3239700f..ec6d4e196e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -57,7 +57,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: ModalManager.end.closeModals() } withBGApi { - m.controller.changeActiveUser(user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) + m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) } }, removeUser = { user -> @@ -106,24 +106,24 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: ModalManager.start.showModalCloseable(true) { close -> ProfileActionView(UserProfileAction.UNHIDE, user) { pwd -> withBGApi { - setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, pwd) } + setUserPrivacy(m) { m.controller.apiUnhideUser(user, pwd) } close() } } } } else { - withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, searchTextOrPassword.value.trim()) } } + withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user, searchTextOrPassword.value.trim()) } } } }, muteUser = { user -> withBGApi { setUserPrivacy(m, onSuccess = { if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert) - }) { m.controller.apiMuteUser(user.userId) } + }) { m.controller.apiMuteUser(user) } } }, unmuteUser = { user -> - withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId) } } + withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user) } } }, showHiddenProfile = { user -> ModalManager.start.showModalCloseable(true) { close -> @@ -348,14 +348,14 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List, de if (users.size < 2) return suspend fun deleteUser(user: User) { - m.controller.apiDeleteUser(user.userId, delSMPQueues, viewPwd) + m.controller.apiDeleteUser(user, delSMPQueues, viewPwd) m.removeUser(user) } try { if (user.activeUser) { val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden } if (newActive != null) { - m.controller.changeActiveUser_(newActive.userId, null) + m.controller.changeActiveUser_(newActive.remoteHostId, newActive.userId, null) deleteUser(user.copy(activeUser = false)) } } else { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index cce8a3ce85..c2665109f5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -50,22 +50,23 @@ actual fun ActiveCallView() { val call = chatModel.activeCall.value if (call != null) { Log.d(TAG, "has active call $call") + val callRh = call.remoteHostId when (val r = apiMsg.resp) { is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) - chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } is WCallResponse.Offer -> withBGApi { - chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } is WCallResponse.Answer -> withBGApi { - chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { @@ -73,7 +74,7 @@ actual fun ActiveCallView() { if (callStatus == WebRTCCallStatus.Connected) { chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) } - withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Error) { Log.d(TAG, "call status ${r.state.connectionState} not used") } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index d2fa97f860..61e0e0d3ce 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -44,7 +44,7 @@ actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow Unit) { - PasteToConnectView(m, close) +actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { + // TODO this should close if remote host changes in model + PasteToConnectView(m, rhId, close) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt index f202318f16..540de40a95 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt @@ -4,9 +4,10 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatModel @Composable -actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { ConnectContactLayout( chatModel = chatModel, + rhId = rhId, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt index 464c286316..2d436dbbf0 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt @@ -4,6 +4,6 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.ServerCfg @Composable -actual fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { - ScanProtocolServerLayout(onNext) +actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { + ScanProtocolServerLayout(rhId, onNext) } From 07ef2a0b6473f1ae16ae60372f54505e22d9e5e6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:20:31 +0000 Subject: [PATCH 224/294] android: remove ACCESS_WIFI_STATE (#3391) --- apps/multiplatform/android/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 459b1bc05a..09b33316a4 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -15,7 +15,6 @@ - From c536ca7f0f81dc72905e9e56caccb767652c2621 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:34:24 +0000 Subject: [PATCH 225/294] core: add events not sent to connected remote desktop (#3402) --- src/Simplex/Chat/Controller.hs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2ccc2ca12e..7533649c69 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -686,17 +686,29 @@ data ChatResponse | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show) +-- some of these can only be used as command responses allowRemoteEvent :: ChatResponse -> Bool allowRemoteEvent = \case - CRRemoteHostList {} -> False - CRRemoteHostConnected {} -> False - CRRemoteHostStopped {} -> False - CRRemoteCtrlList {} -> False - CRRemoteCtrlFound {} -> False + CRChatStarted -> False + CRChatRunning -> False + CRChatStopped -> False + CRChatSuspended -> False + CRRemoteHostList _ -> False + CRCurrentRemoteHost _ -> False + CRRemoteHostStarted {} -> False + CRRemoteHostSessionCode {} -> False + CRNewRemoteHost _ -> False + CRRemoteHostConnected _ -> False + CRRemoteHostStopped _ -> False + CRRemoteFileStored {} -> False + CRRemoteCtrlList _ -> False + CRRemoteCtrlFound _ -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlSessionCode {} -> False - CRRemoteCtrlConnected {} -> False + CRRemoteCtrlConnected _ -> False CRRemoteCtrlStopped -> False + CRSQLResult _ -> False + CRSlowSQLQueries {} -> False _ -> True logResponseToFile :: ChatResponse -> Bool From 718436bf55ec2fca22e028b51bc138e1c6fb81e9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 15:27:15 +0400 Subject: [PATCH 226/294] core: don't read all group members where unnecessary (#3403) --- src/Simplex/Chat.hs | 79 +++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5ec22602fd..06a603584b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3252,10 +3252,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do pure $ updateEntityConnStatus acEntity connStatus Nothing -> pure acEntity - isMember :: MemberId -> GroupInfo -> [GroupMember] -> Bool - isMember memId GroupInfo {membership} members = - sameMemberId memId membership || isJust (find (sameMemberId memId) members) - agentMsgConnStatus :: ACommand 'Agent e -> Maybe ConnStatus agentMsgConnStatus = \case CONF {} -> Just ConnRequested @@ -3533,7 +3529,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () CON -> do - members <- withStore' $ \db -> getGroupMembers db user gInfo withStore' $ \db -> do updateGroupMemberStatus db userId m GSMemConnected unless (memberActive membership) $ @@ -3553,6 +3548,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} let Connection {viaUserContactLink} = conn when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem + members <- withStore' $ \db -> getGroupMembers db user gInfo intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> @@ -4943,11 +4939,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> m () xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg brokerTs = do checkHostRole m memRole - members <- withStore' $ \db -> getGroupMembers db user gInfo unless (sameMemberId memId $ membership gInfo) $ - if isMember memId gInfo members - then messageError "x.grp.mem.new error: member already exists" - else do + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Right _ -> messageError "x.grp.mem.new error: member already exists" + Left _ -> do newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) groupMsgToView gInfo ci @@ -4956,11 +4951,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) = do case memberCategory m of - GCHostMember -> do - members <- withStore' $ \db -> getGroupMembers db user gInfo - if isMember memId gInfo members - then messageWarning "x.grp.mem.intro ignored: member already exists" - else do + GCHostMember -> + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Right _ -> messageError "x.grp.mem.intro ignored: member already exists" + Left _ -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) subMode <- chatReadVar subscriptionMode -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second @@ -4986,11 +4980,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemInv :: GroupInfo -> GroupMember -> MemberId -> IntroInvitation -> m () xGrpMemInv gInfo@GroupInfo {groupId} m memId introInv = do case memberCategory m of - GCInviteeMember -> do - members <- withStore' $ \db -> getGroupMembers db user gInfo - case find (sameMemberId memId) members of - Nothing -> messageError "x.grp.mem.inv error: referenced member does not exist" - Just reMember -> do + GCInviteeMember -> + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" + Right reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv void . sendGroupMessage' user [reMember] (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded @@ -4999,14 +4992,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole - members <- withStore' $ \db -> getGroupMembers db user gInfo - toMember <- case find (sameMemberId memId) members of - -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent - -- the situation when member does not exist is an error - -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. - -- For now, this branch compensates for the lack of delayed message delivery. - Nothing -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced - Just m' -> pure m' + toMember <- + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent + -- the situation when member does not exist is an error + -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. + -- For now, this branch compensates for the lack of delayed message delivery. + Left _ -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + Right m' -> pure m' withStore' $ \db -> saveMemberInvitation db toMember introInv subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito @@ -5023,11 +5016,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | membership.memberId == memId = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole - | otherwise = do - members <- withStore' $ \db -> getGroupMembers db user gInfo - case find (sameMemberId memId) members of - Just member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole - _ -> messageError "x.grp.mem.role with unknown member ID" + | otherwise = + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole + Left _ -> messageError "x.grp.mem.role with unknown member ID" where changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" @@ -5081,25 +5073,26 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> m () xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId msg brokerTs = do - members <- withStore' $ \db -> getGroupMembers db user gInfo if membership.memberId == memId then checkRole membership $ do deleteGroupLinkIfExists user gInfo -- member records are not deleted to keep history + members <- withStore' $ \db -> getGroupMembers db user gInfo deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved deleteMemberItem RGEUserDeleted toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m - else case find (sameMemberId memId) members of - Nothing -> messageError "x.grp.mem.del with unknown member ID" - Just member@GroupMember {groupMemberId, memberProfile} -> - checkRole member $ do - -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave - deleteMemberConnection user member - -- undeleted "member connected" chat item will prevent deletion of member record - deleteOrUpdateMemberRecord user member - deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} + else + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Left _ -> messageError "x.grp.mem.del with unknown member ID" + Right member@GroupMember {groupMemberId, memberProfile} -> + checkRole member $ do + -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave + deleteMemberConnection user member + -- undeleted "member connected" chat item will prevent deletion of member record + deleteOrUpdateMemberRecord user member + deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = From 53a31ec60ed2af6c93c83b013631b77697ec2718 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 11:51:40 +0000 Subject: [PATCH 227/294] core: add remote host ID to entities --- .../chat/simplex/common/views/call/CallView.android.kt | 2 ++ .../kotlin/chat/simplex/common/model/ChatModel.kt | 8 +++++--- .../simplex/common/views/call/IncomingCallAlertView.kt | 1 + .../kotlin/chat/simplex/common/views/call/WebRTC.kt | 4 ++-- .../kotlin/chat/simplex/common/views/chat/ChatInfoView.kt | 1 + .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 2 ++ .../simplex/common/views/chat/group/GroupChatInfoView.kt | 1 + .../simplex/common/views/chatlist/ChatListNavLinkView.kt | 2 ++ 8 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index c173463d5e..51c3623250 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -579,6 +579,7 @@ fun PreviewActiveCallOverlayVideo() { SimpleXTheme { ActiveCallOverlayLayout( call = Call( + remoteHostId = null, contact = Contact.sampleData, callState = CallState.Negotiated, localMedia = CallMediaType.Video, @@ -604,6 +605,7 @@ fun PreviewActiveCallOverlayAudio() { SimpleXTheme { ActiveCallOverlayLayout( call = Call( + remoteHostId = null, contact = Contact.sampleData, callState = CallState.Negotiated, localMedia = CallMediaType.Audio, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ab8b6af3fd..179a4e846a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -616,7 +616,7 @@ enum class ChatType(val type: String) { @Serializable data class User( - val remoteHostId: Long? = null, + val remoteHostId: Long?, override val userId: Long, val userContactId: Long, val localDisplayName: String, @@ -639,6 +639,7 @@ data class User( companion object { val sampleData = User( + remoteHostId = null, userId = 1, userContactId = 1, localDisplayName = "alice", @@ -715,8 +716,8 @@ interface SomeChat { } @Serializable @Stable -data class Chat ( - val remoteHostId: Long? = null, +data class Chat( + val remoteHostId: Long?, val chatInfo: ChatInfo, val chatItems: List, val chatStats: ChatStats = ChatStats() @@ -749,6 +750,7 @@ data class Chat ( companion object { val sampleData = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf(ChatItem.getSampleData()) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 447236286f..47dd0a27d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -106,6 +106,7 @@ fun PreviewIncomingCallAlertLayout() { SimpleXTheme { IncomingCallAlertLayout( invitation = RcvCallInvitation( + remoteHostId = null, user = User.sampleData, contact = Contact.sampleData, callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 64904ba7a2..6a357d26ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -11,7 +11,7 @@ import java.util.* import kotlin.collections.ArrayList data class Call( - val remoteHostId: Long? = null, + val remoteHostId: Long?, val contact: Contact, val callState: CallState, val localMedia: CallMediaType, @@ -97,7 +97,7 @@ sealed class WCallResponse { @Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) @Serializable data class RcvCallInvitation( - val remoteHostId: Long? = null, + val remoteHostId: Long?, val user: User, val contact: Contact, val callType: CallType, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 5816c89524..694ec2ba18 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -718,6 +718,7 @@ fun PreviewChatInfoLayout() { SimpleXTheme { ChatInfoLayout( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf() ), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 862212217f..5975f674e3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1418,6 +1418,7 @@ fun PreviewChatLayout() { val searchValue = remember { mutableStateOf("") } ChatLayout( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = chatItems, chatStats = Chat.ChatStats() @@ -1490,6 +1491,7 @@ fun PreviewGroupChatLayout() { val searchValue = remember { mutableStateOf("") } ChatLayout( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Group.sampleData, chatItems = chatItems, chatStats = Chat.ChatStats() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 49d76d8ecd..d3b1841fe0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -524,6 +524,7 @@ fun PreviewGroupChatInfoLayout() { SimpleXTheme { GroupChatInfoLayout( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf() ), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 7e81faf3d5..e121f9ee78 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -772,6 +772,7 @@ fun PreviewChatListNavLinkDirect() { chatLinkPreview = { ChatPreviewView( chat = Chat( + remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, chatItems = listOf( ChatItem.getSampleData( @@ -815,6 +816,7 @@ fun PreviewChatListNavLinkGroup() { chatLinkPreview = { ChatPreviewView( Chat( + remoteHostId = null, chatInfo = ChatInfo.Group.sampleData, chatItems = listOf( ChatItem.getSampleData( From 44c88baddea7888fa69724fdee2b2f674da5d8f6 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:31:52 +0400 Subject: [PATCH 228/294] remoteHostId in entities kotlin --- .../chat/simplex/app/views/call/IncomingCallActivity.kt | 1 + .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt index 104b654c58..f5c46a0eb5 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt @@ -231,6 +231,7 @@ fun PreviewIncomingCallLockScreenAlert() { ) { IncomingCallLockScreenAlertLayout( invitation = RcvCallInvitation( + remoteHostId = null, user = User.sampleData, contact = Contact.sampleData, callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index ffb5b4251b..33743974cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2531,7 +2531,7 @@ data class UserProtocolServers( @Serializable data class ServerCfg( - val remoteHostId: Long? = null, + val remoteHostId: Long?, val server: String, val preset: Boolean, val tested: Boolean? = null, @@ -2549,7 +2549,7 @@ data class ServerCfg( get() = server.isBlank() companion object { - val empty = ServerCfg(server = "", preset = false, tested = null, enabled = true) + val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = true) class SampleData( val preset: ServerCfg, @@ -2559,18 +2559,21 @@ data class ServerCfg( val sampleData = SampleData( preset = ServerCfg( + remoteHostId = null, server = "smp://abcd@smp8.simplex.im", preset = true, tested = true, enabled = true ), custom = ServerCfg( + remoteHostId = null, server = "smp://abcd@smp9.simplex.im", preset = false, tested = false, enabled = false ), untested = ServerCfg( + remoteHostId = null, server = "smp://abcd@smp10.simplex.im", preset = false, tested = null, From 307a1b3c5e171ebcba566c1016a5e0adc75303c5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 13:23:45 +0000 Subject: [PATCH 229/294] fix for ghc 8.10.7 --- simplex-chat.cabal | 14 +++++++------- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Store/Files.hs | 3 +-- src/Simplex/Chat/Types.hs | 1 - src/Simplex/Chat/View.hs | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index c3791ac11d..f238a99f34 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -186,7 +186,7 @@ library , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport ==0.5.4 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -238,7 +238,7 @@ executable simplex-bot , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport ==0.5.4 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -291,7 +291,7 @@ executable simplex-bot-advanced , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport ==0.5.4 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -346,7 +346,7 @@ executable simplex-broadcast-bot , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport ==0.5.4 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -400,7 +400,7 @@ executable simplex-chat , memory ==0.15.* , mtl ==2.2.* , network ==3.1.* - , network-transport ==0.5.6 + , network-transport ==0.5.4 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -458,7 +458,7 @@ executable simplex-directory-service , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport ==0.5.4 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -542,7 +542,7 @@ test-suite simplex-chat-test , memory ==0.15.* , mtl ==2.2.* , network ==3.1.* - , network-transport ==0.5.6 + , network-transport ==0.5.4 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a1728b5dc7..bac065f7ac 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2247,7 +2247,7 @@ processChatCommand = \case forwardFile chatName fileId sendCommand = withUser $ \user -> do withStore (\db -> getFileTransfer db user fileId) >>= \case FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}, cryptoArgs} -> forward filePath cryptoArgs - FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \f -> f.cryptoArgs + FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \XFTPSndFile {cryptoArgs} -> cryptoArgs _ -> throwChatError CEFileNotReceived {fileId} where forward path cfArgs = processChatCommand . sendCommand chatName $ CryptoFile path cfArgs diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index e3b1d44621..996a44b06f 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -2,7 +2,6 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -904,7 +903,7 @@ getLocalCryptoFile db userId fileId sent = _ -> do unless sent $ throwError $ SEFileNotFound fileId FileTransferMeta {filePath, xftpSndFile} <- getFileTransferMeta_ db userId fileId - pure $ CryptoFile filePath $ xftpSndFile >>= \f -> f.cryptoArgs + pure $ CryptoFile filePath $ xftpSndFile >>= \XFTPSndFile {cryptoArgs} -> cryptoArgs updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem updateDirectCIFileStatus db user fileId fileStatus = do diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 34f2a4f108..ddeee38d88 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -9,7 +9,6 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 60d2a4cba6..314139f20c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -487,7 +487,7 @@ viewGroupSubscribed :: GroupInfo -> [StyledString] viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] showSMPServer :: SMPServer -> String -showSMPServer srv = B.unpack $ strEncode srv.host +showSMPServer ProtocolServer {host} = B.unpack $ strEncode host viewHostEvent :: AProtocolType -> TransportHost -> String viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h) From 49a9b0e7d6ff48e2f5ca06165f0e0253ae383715 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 14:30:10 +0000 Subject: [PATCH 230/294] update hpack version --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f238a99f34..df38e71f4e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.36.0. +-- This file has been generated from package.yaml by hpack version 0.35.2. -- -- see: https://github.com/sol/hpack From 4fecfe825a9dea4423716b9ad93263a0fc843211 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 14:34:25 +0000 Subject: [PATCH 231/294] update hpack --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index df38e71f4e..bce79aeac5 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.35.2. +-- This file has been generated from package.yaml by hpack version 0.35.0. -- -- see: https://github.com/sol/hpack From 55ead740cc0d88ad2b6a22de350ea088d84747f3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 14:34:25 +0000 Subject: [PATCH 232/294] update hpack --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index df38e71f4e..bce79aeac5 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.35.2. +-- This file has been generated from package.yaml by hpack version 0.35.0. -- -- see: https://github.com/sol/hpack From 72654caca65d8c1e5c6e886b5fd5f17a7e26e13f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:40:24 +0400 Subject: [PATCH 233/294] remoteHostId in backend --- src/Simplex/Chat.hs | 12 +- src/Simplex/Chat/Call.hs | 8 +- src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Remote.hs | 6 +- src/Simplex/Chat/Remote/Protocol.hs | 232 +++++++++++++++++++++++++++- src/Simplex/Chat/Remote/Types.hs | 4 +- src/Simplex/Chat/Store/Messages.hs | 20 +-- src/Simplex/Chat/Store/Profiles.hs | 4 +- src/Simplex/Chat/Store/Remote.hs | 1 + src/Simplex/Chat/Store/Shared.hs | 2 +- src/Simplex/Chat/Terminal/Output.hs | 1 - src/Simplex/Chat/Types.hs | 8 +- src/Simplex/Chat/View.hs | 10 +- 13 files changed, 267 insertions(+), 44 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5ec22602fd..329285a6ec 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1069,7 +1069,7 @@ processChatCommand = \case callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} (msg, _) <- sendDirectContactMessage ct (XCallInv callId invitation) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) - let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} + let call' = Call {remoteHostId = Nothing, contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) @@ -1141,7 +1141,7 @@ processChatCommand = \case rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withStore $ \db -> do user <- getUserByContactId db contactId contact <- getContact db user contactId - pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} + pure RcvCallInvitation {remoteHostId = Nothing, user, contact, callType = peerCallType, sharedKey, callTs} APIGetNetworkStatuses -> withUser $ \_ -> CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> @@ -1186,7 +1186,7 @@ processChatCommand = \case servers' = fromMaybe (L.map toServerCfg defServers) $ nonEmpty servers pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p servers' defServers where - toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} + toServerCfg server = ServerCfg {remoteHostId = Nothing, server, preset = True, tested = Nothing, enabled = True} GetUserProtoServers aProtocol -> withUser $ \User {userId} -> processChatCommand $ APIGetUserProtoServers userId aProtocol APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) -> withUserId userId $ \user -> withServerProtocol p $ @@ -4766,7 +4766,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveCallItem CISCallPending let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} - call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} + call' = Call {remoteHostId = Nothing, contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} calls <- asks currentCalls -- theoretically, the new call invitation for the current contact can mark the in-progress call as ended -- (and replace it in ChatController) @@ -4774,7 +4774,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} + toView $ CRCallInvitation RcvCallInvitation {remoteHostId = Nothing, user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} toView $ CRNewChatItem user $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci else featureRejected CFCalls where @@ -6314,7 +6314,7 @@ chatCommandP = (Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP))) (pure Nothing) srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) - toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} + toServerCfg server = ServerCfg {remoteHostId = Nothing, server, preset = False, tested = Nothing, enabled = True} char_ = optional . A.char adminContactReq :: ConnReqContact diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 313442838e..39481db4af 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -20,14 +20,15 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import Simplex.Chat.Types (Contact, ContactId, User) +import Simplex.Chat.Types (Contact, ContactId, User, RemoteHostId) import Simplex.Chat.Types.Util (decodeJSON, encodeJSON) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) data Call = Call - { contactId :: ContactId, + { remoteHostId :: Maybe RemoteHostId, + contactId :: ContactId, callId :: CallId, chatItemId :: Int64, callState :: CallState, @@ -107,7 +108,8 @@ instance FromField CallId where fromField f = CallId <$> fromField f instance ToField CallId where toField (CallId m) = toField m data RcvCallInvitation = RcvCallInvitation - { user :: User, + { remoteHostId :: Maybe RemoteHostId, + user :: User, contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key, diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 9e4c309910..88ad946058 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -266,7 +266,8 @@ data NewChatItem d = NewChatItem -- | type to show one chat with messages data Chat c = Chat - { chatInfo :: ChatInfo c, + { remoteHostId :: Maybe RemoteHostId, + chatInfo :: ChatInfo c, chatItems :: [CChatItem c], chatStats :: ChatStats } diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d9ef5bd648..da1b0f9d78 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -225,7 +225,7 @@ startRemoteHost rh_ = do pollEvents rhId rhClient = do oq <- asks outputQ forever $ do - r_ <- liftRH rhId $ remoteRecv rhClient 10000000 + r_ <- liftRH rhId $ remoteRecv rhId rhClient 10000000 forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) httpError :: RemoteHostId -> HTTP2ClientError -> ChatError httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow @@ -359,13 +359,13 @@ processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostClient -> ChatC processRemoteCommand remoteHostId c cmd s = case cmd of SendFile chatName f -> sendFile "/f" chatName f SendImage chatName f -> sendFile "/img" chatName f - _ -> liftRH remoteHostId $ remoteSend c s + _ -> liftRH remoteHostId $ remoteSend remoteHostId c s where sendFile cmdName chatName (CryptoFile path cfArgs) = do -- don't encrypt in host if already encrypted locally CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption - liftRH remoteHostId $ remoteSend c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + liftRH remoteHostId $ remoteSend remoteHostId c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] cryptoFileStr CryptoFile {filePath, cryptoArgs} = maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs <> encodeUtf8 (T.pack filePath) diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index c1acee1e0f..e70041e9c7 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -8,6 +8,7 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} module Simplex.Chat.Remote.Protocol where @@ -34,9 +35,12 @@ import Data.Word (Word32) import qualified Network.HTTP.Types as N import qualified Network.HTTP2.Client as H import Network.Transport.Internal (decodeWord32, encodeWord32) +import Simplex.Chat.Call (RcvCallInvitation (..)) import Simplex.Chat.Controller +import Simplex.Chat.Messages (AChat (..), Chat (..)) import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types +import Simplex.Chat.Types (RemoteHostId, User (..), UserInfo (..), ServerCfg (..)) import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Messaging.Agent.Client (agentDRG) import qualified Simplex.Messaging.Crypto as C @@ -105,18 +109,232 @@ closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client -- ** Commands -remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse -remoteSend c cmd = +remoteSend :: RemoteHostId -> RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse +remoteSend rhId c cmd = sendRemoteCommand' c Nothing RCSend {command = decodeUtf8 cmd} >>= \case - RRChatResponse cr -> pure cr + RRChatResponse cr -> pure $ enrichCRRemoteHostId cr rhId r -> badResponse r -remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) -remoteRecv c ms = +remoteRecv :: RemoteHostId -> RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) +remoteRecv rhId c ms = sendRemoteCommand' c Nothing RCRecv {wait = ms} >>= \case - RRChatEvent cr_ -> pure cr_ + RRChatEvent cr_ -> pure $ (`enrichCRRemoteHostId` rhId) <$> cr_ r -> badResponse r +enrichCRRemoteHostId :: ChatResponse -> RemoteHostId -> ChatResponse +enrichCRRemoteHostId cr rhId = case cr of + CRActiveUser {user} -> CRActiveUser {user = enrichUser user} + CRUsersList {users} -> CRUsersList {users = map enrichUserInfo users} + CRChatStarted -> cr + CRChatRunning -> cr + CRChatStopped -> cr + CRChatSuspended -> cr + CRApiChats {user, chats} -> CRApiChats {user = enrichUser user, chats = map enrichAChat chats} + CRChats {chats} -> CRChats {chats = map enrichAChat chats} + CRApiChat {user, chat} -> CRApiChat {user = enrichUser user, chat = enrichAChat chat} + CRChatItems {user, chatName_, chatItems} -> CRChatItems {user = enrichUser user, chatName_, chatItems} + CRChatItemInfo {user, chatItem, chatItemInfo} -> CRChatItemInfo {user = enrichUser user, chatItem, chatItemInfo} + CRChatItemId user chatItemId_ -> CRChatItemId (enrichUser user) chatItemId_ + CRApiParsedMarkdown {formattedText = _ft} -> cr + CRUserProtoServers {user, servers} -> CRUserProtoServers {user = enrichUser user, servers = enrichAUserProtoServers servers} + CRServerTestResult {user, testServer, testFailure} -> CRServerTestResult {user = enrichUser user, testServer, testFailure} + CRChatItemTTL {user, chatItemTTL} -> CRChatItemTTL {user = enrichUser user, chatItemTTL} + CRNetworkConfig {networkConfig = _nc} -> cr + CRContactInfo {user, contact, connectionStats_, customUserProfile} -> CRContactInfo {user = enrichUser user, contact, connectionStats_, customUserProfile} + CRGroupInfo {user, groupInfo, groupSummary} -> CRGroupInfo {user = enrichUser user, groupInfo, groupSummary} + CRGroupMemberInfo {user, groupInfo, member, connectionStats_} -> CRGroupMemberInfo {user = enrichUser user, groupInfo, member, connectionStats_} + CRContactSwitchStarted {user, contact, connectionStats} -> CRContactSwitchStarted {user = enrichUser user, contact, connectionStats} + CRGroupMemberSwitchStarted {user, groupInfo, member, connectionStats} -> CRGroupMemberSwitchStarted {user = enrichUser user, groupInfo, member, connectionStats} + CRContactSwitchAborted {user, contact, connectionStats} -> CRContactSwitchAborted {user = enrichUser user, contact, connectionStats} + CRGroupMemberSwitchAborted {user, groupInfo, member, connectionStats} -> CRGroupMemberSwitchAborted {user = enrichUser user, groupInfo, member, connectionStats} + CRContactSwitch {user, contact, switchProgress} -> CRContactSwitch {user = enrichUser user, contact, switchProgress} + CRGroupMemberSwitch {user, groupInfo, member, switchProgress} -> CRGroupMemberSwitch {user = enrichUser user, groupInfo, member, switchProgress} + CRContactRatchetSyncStarted {user, contact, connectionStats} -> CRContactRatchetSyncStarted {user = enrichUser user, contact, connectionStats} + CRGroupMemberRatchetSyncStarted {user, groupInfo, member, connectionStats} -> CRGroupMemberRatchetSyncStarted {user = enrichUser user, groupInfo, member, connectionStats} + CRContactRatchetSync {user, contact, ratchetSyncProgress} -> CRContactRatchetSync {user = enrichUser user, contact, ratchetSyncProgress} + CRGroupMemberRatchetSync {user, groupInfo, member, ratchetSyncProgress} -> CRGroupMemberRatchetSync {user = enrichUser user, groupInfo, member, ratchetSyncProgress} + CRContactVerificationReset {user, contact} -> CRContactVerificationReset {user = enrichUser user, contact} + CRGroupMemberVerificationReset {user, groupInfo, member} -> CRGroupMemberVerificationReset {user = enrichUser user, groupInfo, member} + CRContactCode {user, contact, connectionCode} -> CRContactCode {user = enrichUser user, contact, connectionCode} + CRGroupMemberCode {user, groupInfo, member, connectionCode} -> CRGroupMemberCode {user = enrichUser user, groupInfo, member, connectionCode} + CRConnectionVerified {user, verified, expectedCode} -> CRConnectionVerified {user = enrichUser user, verified, expectedCode} + CRNewChatItem {user, chatItem} -> CRNewChatItem {user = enrichUser user, chatItem} + CRChatItemStatusUpdated {user, chatItem} -> CRChatItemStatusUpdated {user = enrichUser user, chatItem} + CRChatItemUpdated {user, chatItem} -> CRChatItemUpdated {user = enrichUser user, chatItem} + CRChatItemNotChanged {user, chatItem} -> CRChatItemNotChanged {user = enrichUser user, chatItem} + CRChatItemReaction {user, added, reaction} -> CRChatItemReaction {user = enrichUser user, added, reaction} + CRChatItemDeleted {user, deletedChatItem, toChatItem, byUser, timed} -> CRChatItemDeleted {user = enrichUser user, deletedChatItem, toChatItem, byUser, timed} + CRChatItemDeletedNotFound {user, contact, sharedMsgId} -> CRChatItemDeletedNotFound {user = enrichUser user, contact, sharedMsgId} + CRBroadcastSent {user, msgContent, successes, failures, timestamp} -> CRBroadcastSent {user = enrichUser user, msgContent, successes, failures, timestamp} + CRMsgIntegrityError {user, msgError} -> CRMsgIntegrityError {user = enrichUser user, msgError} + CRCmdAccepted {corr = _corr} -> cr + CRCmdOk {user_} -> CRCmdOk {user_ = enrichUser <$> user_} + CRChatHelp {helpSection = _hs} -> cr + CRWelcome {user} -> CRWelcome {user = enrichUser user} + CRGroupCreated {user, groupInfo} -> CRGroupCreated {user = enrichUser user, groupInfo} + CRGroupMembers {user, group} -> CRGroupMembers {user = enrichUser user, group} + CRContactsList {user, contacts} -> CRContactsList {user = enrichUser user, contacts} + CRUserContactLink {user, contactLink} -> CRUserContactLink {user = enrichUser user, contactLink} + CRUserContactLinkUpdated {user, contactLink} -> CRUserContactLinkUpdated {user = enrichUser user, contactLink} + CRContactRequestRejected {user, contactRequest} -> CRContactRequestRejected {user = enrichUser user, contactRequest} + CRUserAcceptedGroupSent {user, groupInfo, hostContact} -> CRUserAcceptedGroupSent {user = enrichUser user, groupInfo, hostContact} + CRGroupLinkConnecting {user, groupInfo, hostMember} -> CRGroupLinkConnecting {user = enrichUser user, groupInfo, hostMember} + CRUserDeletedMember {user, groupInfo, member} -> CRUserDeletedMember {user = enrichUser user, groupInfo, member} + CRGroupsList {user, groups} -> CRGroupsList {user = enrichUser user, groups} + CRSentGroupInvitation {user, groupInfo, contact, member} -> CRSentGroupInvitation {user = enrichUser user, groupInfo, contact, member} + CRFileTransferStatus user ftStatus -> CRFileTransferStatus (enrichUser user) ftStatus + CRFileTransferStatusXFTP user chatItem -> CRFileTransferStatusXFTP (enrichUser user) chatItem + CRUserProfile {user, profile} -> CRUserProfile {user = enrichUser user, profile} + CRUserProfileNoChange {user} -> CRUserProfileNoChange {user = enrichUser user} + CRUserPrivacy {user, updatedUser} -> CRUserPrivacy {user = enrichUser user, updatedUser = enrichUser updatedUser} + CRVersionInfo {versionInfo = _v, chatMigrations = _cm, agentMigrations = _am} -> cr + CRInvitation {user, connReqInvitation, connection} -> CRInvitation {user = enrichUser user, connReqInvitation, connection} + CRConnectionIncognitoUpdated {user, toConnection} -> CRConnectionIncognitoUpdated {user = enrichUser user, toConnection} + CRConnectionPlan {user, connectionPlan} -> CRConnectionPlan {user = enrichUser user, connectionPlan} + CRSentConfirmation {user} -> CRSentConfirmation {user = enrichUser user} + CRSentInvitation {user, customUserProfile} -> CRSentInvitation {user = enrichUser user, customUserProfile} + CRSentInvitationToContact {user, contact, customUserProfile} -> CRSentInvitationToContact {user = enrichUser user, contact, customUserProfile} + CRContactUpdated {user, fromContact, toContact} -> CRContactUpdated {user = enrichUser user, fromContact, toContact} + CRGroupMemberUpdated {user, groupInfo, fromMember, toMember} -> CRGroupMemberUpdated {user = enrichUser user, groupInfo, fromMember, toMember} + CRContactsMerged {user, intoContact, mergedContact, updatedContact} -> CRContactsMerged {user = enrichUser user, intoContact, mergedContact, updatedContact} + CRContactDeleted {user, contact} -> CRContactDeleted {user = enrichUser user, contact} + CRContactDeletedByContact {user, contact} -> CRContactDeletedByContact {user = enrichUser user, contact} + CRChatCleared {user, chatInfo} -> CRChatCleared {user = enrichUser user, chatInfo} + CRUserContactLinkCreated {user, connReqContact} -> CRUserContactLinkCreated {user = enrichUser user, connReqContact} + CRUserContactLinkDeleted {user} -> CRUserContactLinkDeleted {user = enrichUser user} + CRReceivedContactRequest {user, contactRequest} -> CRReceivedContactRequest {user = enrichUser user, contactRequest} + CRAcceptingContactRequest {user, contact} -> CRAcceptingContactRequest {user = enrichUser user, contact} + CRContactAlreadyExists {user, contact} -> CRContactAlreadyExists {user = enrichUser user, contact} + CRContactRequestAlreadyAccepted {user, contact} -> CRContactRequestAlreadyAccepted {user = enrichUser user, contact} + CRLeftMemberUser {user, groupInfo} -> CRLeftMemberUser {user = enrichUser user, groupInfo} + CRGroupDeletedUser {user, groupInfo} -> CRGroupDeletedUser {user = enrichUser user, groupInfo} + CRRcvFileDescrReady {user, chatItem} -> CRRcvFileDescrReady {user = enrichUser user, chatItem} + CRRcvFileAccepted {user, chatItem} -> CRRcvFileAccepted {user = enrichUser user, chatItem} + CRRcvFileAcceptedSndCancelled {user, rcvFileTransfer} -> CRRcvFileAcceptedSndCancelled {user = enrichUser user, rcvFileTransfer} + CRRcvFileDescrNotReady {user, chatItem} -> CRRcvFileDescrNotReady {user = enrichUser user, chatItem} + CRRcvFileStart {user, chatItem} -> CRRcvFileStart {user = enrichUser user, chatItem} + CRRcvFileProgressXFTP {user, chatItem, receivedSize, totalSize} -> CRRcvFileProgressXFTP {user = enrichUser user, chatItem, receivedSize, totalSize} + CRRcvFileComplete {user, chatItem} -> CRRcvFileComplete {user = enrichUser user, chatItem} + CRRcvFileCancelled {user, chatItem, rcvFileTransfer} -> CRRcvFileCancelled {user = enrichUser user, chatItem, rcvFileTransfer} + CRRcvFileSndCancelled {user, chatItem, rcvFileTransfer} -> CRRcvFileSndCancelled {user = enrichUser user, chatItem, rcvFileTransfer} + CRRcvFileError {user, chatItem, agentError} -> CRRcvFileError {user = enrichUser user, chatItem, agentError} + CRSndFileStart {user, chatItem, sndFileTransfer} -> CRSndFileStart {user = enrichUser user, chatItem, sndFileTransfer} + CRSndFileComplete {user, chatItem, sndFileTransfer} -> CRSndFileComplete {user = enrichUser user, chatItem, sndFileTransfer} + CRSndFileRcvCancelled {user, chatItem, sndFileTransfer} -> CRSndFileRcvCancelled {user = enrichUser user, chatItem, sndFileTransfer} + CRSndFileCancelled {user, chatItem, fileTransferMeta, sndFileTransfers} -> CRSndFileCancelled {user = enrichUser user, chatItem, fileTransferMeta, sndFileTransfers} + CRSndFileStartXFTP {user, chatItem, fileTransferMeta} -> CRSndFileStartXFTP {user = enrichUser user, chatItem, fileTransferMeta} + CRSndFileProgressXFTP {user, chatItem, fileTransferMeta, sentSize, totalSize} -> CRSndFileProgressXFTP {user = enrichUser user, chatItem, fileTransferMeta, sentSize, totalSize} + CRSndFileCompleteXFTP {user, chatItem, fileTransferMeta} -> CRSndFileCompleteXFTP {user = enrichUser user, chatItem, fileTransferMeta} + CRSndFileCancelledXFTP {user, chatItem, fileTransferMeta} -> CRSndFileCancelledXFTP {user = enrichUser user, chatItem, fileTransferMeta} + CRSndFileError {user, chatItem} -> CRSndFileError {user = enrichUser user, chatItem} + CRUserProfileUpdated {user, fromProfile, toProfile, updateSummary} -> CRUserProfileUpdated {user = enrichUser user, fromProfile, toProfile, updateSummary} + CRUserProfileImage {user, profile} -> CRUserProfileImage {user = enrichUser user, profile} + CRContactAliasUpdated {user, toContact} -> CRContactAliasUpdated {user = enrichUser user, toContact} + CRConnectionAliasUpdated {user, toConnection} -> CRConnectionAliasUpdated {user = enrichUser user, toConnection} + CRContactPrefsUpdated {user, fromContact, toContact} -> CRContactPrefsUpdated {user = enrichUser user, fromContact, toContact} + CRContactConnecting {user, contact} -> CRContactConnecting {user = enrichUser user, contact} + CRContactConnected {user, contact, userCustomProfile} -> CRContactConnected {user = enrichUser user, contact, userCustomProfile} + CRContactAnotherClient {user, contact} -> CRContactAnotherClient {user = enrichUser user, contact} + CRSubscriptionEnd {user, connectionEntity} -> CRSubscriptionEnd {user = enrichUser user, connectionEntity} + CRContactsDisconnected {server = _srv, contactRefs = _cr} -> cr + CRContactsSubscribed {server = _srv, contactRefs = _cr} -> cr + CRContactSubError {user, contact, chatError} -> CRContactSubError {user = enrichUser user, contact, chatError} + CRContactSubSummary {user, contactSubscriptions} -> CRContactSubSummary {user = enrichUser user, contactSubscriptions} + CRUserContactSubSummary {user, userContactSubscriptions} -> CRUserContactSubSummary {user = enrichUser user, userContactSubscriptions} + CRNetworkStatus {networkStatus = _ns, connections = _cns} -> cr + CRNetworkStatuses {user_, networkStatuses} -> CRNetworkStatuses {user_ = enrichUser <$> user_, networkStatuses} + CRHostConnected {protocol = _p, transportHost = _th} -> cr + CRHostDisconnected {protocol = _p, transportHost = _th} -> cr + CRGroupInvitation {user, groupInfo} -> CRGroupInvitation {user = enrichUser user, groupInfo} + CRReceivedGroupInvitation {user, groupInfo, contact, fromMemberRole, memberRole} -> CRReceivedGroupInvitation {user = enrichUser user, groupInfo, contact, fromMemberRole, memberRole} + CRUserJoinedGroup {user, groupInfo, hostMember} -> CRUserJoinedGroup {user = enrichUser user, groupInfo, hostMember} + CRJoinedGroupMember {user, groupInfo, member} -> CRJoinedGroupMember {user = enrichUser user, groupInfo, member} + CRJoinedGroupMemberConnecting {user, groupInfo, hostMember, member} -> CRJoinedGroupMemberConnecting {user = enrichUser user, groupInfo, hostMember, member} + CRMemberRole {user, groupInfo, byMember, member, fromRole, toRole} -> CRMemberRole {user = enrichUser user, groupInfo, byMember, member, fromRole, toRole} + CRMemberRoleUser {user, groupInfo, member, fromRole, toRole} -> CRMemberRoleUser {user = enrichUser user, groupInfo, member, fromRole, toRole} + CRConnectedToGroupMember {user, groupInfo, member, memberContact} -> CRConnectedToGroupMember {user = enrichUser user, groupInfo, member, memberContact} + CRDeletedMember {user, groupInfo, byMember, deletedMember} -> CRDeletedMember {user = enrichUser user, groupInfo, byMember, deletedMember} + CRDeletedMemberUser {user, groupInfo, member} -> CRDeletedMemberUser {user = enrichUser user, groupInfo, member} + CRLeftMember {user, groupInfo, member} -> CRLeftMember {user = enrichUser user, groupInfo, member} + CRGroupEmpty {user, groupInfo} -> CRGroupEmpty {user = enrichUser user, groupInfo} + CRGroupRemoved {user, groupInfo} -> CRGroupRemoved {user = enrichUser user, groupInfo} + CRGroupDeleted {user, groupInfo, member} -> CRGroupDeleted {user = enrichUser user, groupInfo, member} + CRGroupUpdated {user, fromGroup, toGroup, member_} -> CRGroupUpdated {user = enrichUser user, fromGroup, toGroup, member_} + CRGroupProfile {user, groupInfo} -> CRGroupProfile {user = enrichUser user, groupInfo} + CRGroupDescription {user, groupInfo} -> CRGroupDescription {user = enrichUser user, groupInfo} + CRGroupLinkCreated {user, groupInfo, connReqContact, memberRole} -> CRGroupLinkCreated {user = enrichUser user, groupInfo, connReqContact, memberRole} + CRGroupLink {user, groupInfo, connReqContact, memberRole} -> CRGroupLink {user = enrichUser user, groupInfo, connReqContact, memberRole} + CRGroupLinkDeleted {user, groupInfo} -> CRGroupLinkDeleted {user = enrichUser user, groupInfo} + CRAcceptingGroupJoinRequest {user, groupInfo, contact} -> CRAcceptingGroupJoinRequest {user = enrichUser user, groupInfo, contact} + CRAcceptingGroupJoinRequestMember {user, groupInfo, member} -> CRAcceptingGroupJoinRequestMember {user = enrichUser user, groupInfo, member} + CRNoMemberContactCreating {user, groupInfo, member} -> CRNoMemberContactCreating {user = enrichUser user, groupInfo, member} + CRNewMemberContact {user, contact, groupInfo, member} -> CRNewMemberContact {user = enrichUser user, contact, groupInfo, member} + CRNewMemberContactSentInv {user, contact, groupInfo, member} -> CRNewMemberContactSentInv {user = enrichUser user, contact, groupInfo, member} + CRNewMemberContactReceivedInv {user, contact, groupInfo, member} -> CRNewMemberContactReceivedInv {user = enrichUser user, contact, groupInfo, member} + CRContactAndMemberAssociated {user, contact, groupInfo, member, updatedContact} -> CRContactAndMemberAssociated {user = enrichUser user, contact, groupInfo, member, updatedContact} + CRMemberSubError {user, groupInfo, member, chatError} -> CRMemberSubError {user = enrichUser user, groupInfo, member, chatError} + CRMemberSubSummary {user, memberSubscriptions} -> CRMemberSubSummary {user = enrichUser user, memberSubscriptions} + CRGroupSubscribed {user, groupInfo} -> CRGroupSubscribed {user = enrichUser user, groupInfo} + CRPendingSubSummary {user, pendingSubscriptions} -> CRPendingSubSummary {user = enrichUser user, pendingSubscriptions} + CRSndFileSubError {user, sndFileTransfer, chatError} -> CRSndFileSubError {user = enrichUser user, sndFileTransfer, chatError} + CRRcvFileSubError {user, rcvFileTransfer, chatError} -> CRRcvFileSubError {user = enrichUser user, rcvFileTransfer, chatError} + CRCallInvitation {callInvitation} -> CRCallInvitation {callInvitation = enrichRcvCallInvitation callInvitation} + CRCallOffer {user, contact, callType, offer, sharedKey, askConfirmation} -> CRCallOffer {user = enrichUser user, contact, callType, offer, sharedKey, askConfirmation} + CRCallAnswer {user, contact, answer} -> CRCallAnswer {user = enrichUser user, contact, answer} + CRCallExtraInfo {user, contact, extraInfo} -> CRCallExtraInfo {user = enrichUser user, contact, extraInfo} + CRCallEnded {user, contact} -> CRCallEnded {user = enrichUser user, contact} + CRCallInvitations {callInvitations} -> CRCallInvitations {callInvitations = map enrichRcvCallInvitation callInvitations} + CRUserContactLinkSubscribed -> cr + CRUserContactLinkSubError {chatError = _ce} -> cr + CRNtfTokenStatus {status = _s} -> cr + CRNtfToken {token = _t, status = _s, ntfMode = _nm} -> cr + CRNtfMessages {user_, connEntity, msgTs, ntfMessages} -> CRNtfMessages {user_ = enrichUser <$> user_, connEntity, msgTs, ntfMessages} + CRNewContactConnection {user, connection} -> CRNewContactConnection {user = enrichUser user, connection} + CRContactConnectionDeleted {user, connection} -> CRContactConnectionDeleted {user = enrichUser user, connection} + CRRemoteHostList {remoteHosts = _rh} -> cr + CRCurrentRemoteHost {remoteHost_ = _rh} -> cr + CRRemoteHostStarted {remoteHost_ = _rh, invitation = _i} -> cr + CRRemoteHostSessionCode {remoteHost_ = _rh, sessionCode = _sc} -> cr + CRNewRemoteHost {remoteHost = _rh} -> cr + CRRemoteHostConnected {remoteHost = _rh} -> cr + CRRemoteHostStopped {remoteHostId_ = _rh} -> cr + CRRemoteFileStored {remoteHostId = _rhId, remoteFileSource = _rfs} -> cr + CRRemoteCtrlList {remoteCtrls = _rc} -> cr + CRRemoteCtrlFound {remoteCtrl = _rc} -> cr + CRRemoteCtrlConnecting {remoteCtrl_ = _rc, ctrlAppInfo = _cai, appVersion = _av} -> cr + CRRemoteCtrlSessionCode {remoteCtrl_ = _rc, sessionCode = _sc} -> cr + CRRemoteCtrlConnected {remoteCtrl = _rc} -> cr + CRRemoteCtrlStopped -> cr + CRSQLResult {rows = _r} -> cr + CRSlowSQLQueries {chatQueries = _cq, agentQueries = _aq} -> cr + CRDebugLocks {chatLockName = _cl, agentLocks = _al} -> cr + CRAgentStats {agentStats = _as} -> cr + CRAgentSubs {activeSubs = _as, pendingSubs = _ps, removedSubs = _rs} -> cr + CRAgentSubsDetails {agentSubs = _as} -> cr + CRConnectionDisabled {connectionEntity = _ce} -> cr + CRAgentRcvQueueDeleted {agentConnId = _acId, server = _srv, agentQueueId = _aqId, agentError_ = _ae} -> cr + CRAgentConnDeleted {agentConnId = _acId} -> cr + CRAgentUserDeleted {agentUserId = _auId} -> cr + CRMessageError {user, severity, errorMessage} -> CRMessageError {user = enrichUser user, severity, errorMessage} + CRChatCmdError {user_, chatError} -> CRChatCmdError {user_ = enrichUser <$> user_, chatError} + CRChatError {user_, chatError} -> CRChatError {user_ = enrichUser <$> user_, chatError} + CRArchiveImported {archiveErrors = _ae} -> cr + CRTimedAction {action = _a, durationMilliseconds = _dm} -> cr + where + enrichUser :: User -> User + enrichUser u = u {remoteHostId = Just rhId} + enrichUserInfo :: UserInfo -> UserInfo + enrichUserInfo uInfo@UserInfo {user} = uInfo {user = enrichUser user} + enrichAChat :: AChat -> AChat + enrichAChat (AChat cType chat) = AChat cType chat {remoteHostId = Just rhId} + enrichServerCfg :: ServerCfg p -> ServerCfg p + enrichServerCfg cfg = cfg {remoteHostId = Just rhId} + enrichAUserProtoServers :: AUserProtoServers -> AUserProtoServers + enrichAUserProtoServers (AUPS ups@UserProtoServers {protoServers}) = AUPS ups {protoServers = fmap enrichServerCfg protoServers} + enrichRcvCallInvitation :: RcvCallInvitation -> RcvCallInvitation + enrichRcvCallInvitation rci = rci {remoteHostId = Just rhId} + + remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath remoteStoreFile c localPath fileName = do (fileSize, fileDigest) <- getFileInfo localPath @@ -140,7 +358,7 @@ sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do - encFile_ <- mapM (prepareEncryptedFile encryption) file_ + encFile_ <- mapM (prepareEncryptedFile encryption) file_ req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd) HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing (header, getNext) <- parseDecryptHTTP2Body encryption response respBody diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 783a083e55..12b5862813 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -19,7 +19,7 @@ import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) import Simplex.Chat.Remote.AppVersion -import Simplex.Chat.Types (verificationCode) +import Simplex.Chat.Types (verificationCode, RemoteHostId) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) @@ -118,8 +118,6 @@ data RemoteProtocolError | RPEException {someException :: Text} deriving (Show, Exception) -type RemoteHostId = Int64 - data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} deriving (Eq, Ord, Show) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b6d455fe86..859ce638e0 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -572,7 +572,7 @@ getDirectChatPreviews_ db user@User {userId} = do let contact = toContact user $ contactRow :. connRow ci_ = toDirectChatItemList currentTs ciRow_ stats = toChatStats statsRow - in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats + in AChat SCTDirect $ Chat Nothing (DirectChat contact) ci_ stats getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] getGroupChatPreviews_ db User {userId, userContactId} = do @@ -645,7 +645,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do let groupInfo = toGroupInfo userContactId groupInfoRow ci_ = toGroupChatItemList currentTs userContactId ciRow_ stats = toChatStats statsRow - in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats + in AChat SCTGroup $ Chat Nothing (GroupChat groupInfo) ci_ stats getContactRequestChatPreviews_ :: DB.Connection -> User -> IO [AChat] getContactRequestChatPreviews_ db User {userId} = @@ -669,7 +669,7 @@ getContactRequestChatPreviews_ db User {userId} = toContactRequestChatPreview cReqRow = let cReq = toContactRequest cReqRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats + in AChat SCTContactRequest $ Chat Nothing (ContactRequest cReq) [] stats getContactConnectionChatPreviews_ :: DB.Connection -> User -> Bool -> IO [AChat] getContactConnectionChatPreviews_ _ _ False = pure [] @@ -688,7 +688,7 @@ getContactConnectionChatPreviews_ db User {userId} _ = toContactConnectionChatPreview connRow = let conn = toPendingContactConnection connRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats + in AChat SCTContactConnection $ Chat Nothing (ContactConnection conn) [] stats getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChat db user contactId pagination search_ = do @@ -703,7 +703,7 @@ getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> Excep getDirectChatLast_ db user ct@Contact {contactId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- getDirectChatItemsLast db user contactId count search - pure $ Chat (DirectChat ct) (reverse chatItems) stats + pure $ Chat Nothing (DirectChat ct) (reverse chatItems) stats -- the last items in reverse order (the last item in the conversation is the first in the returned list) getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect] @@ -733,7 +733,7 @@ getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsAfter_ - pure $ Chat (DirectChat ct) chatItems stats + pure $ Chat Nothing (DirectChat ct) chatItems stats where getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsAfter_ = do @@ -763,7 +763,7 @@ getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsBefore_ - pure $ Chat (DirectChat ct) (reverse chatItems) stats + pure $ Chat Nothing (DirectChat ct) (reverse chatItems) stats where getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsBefore_ = do @@ -803,7 +803,7 @@ getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItemIds <- liftIO getGroupChatItemIdsLast_ chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat (GroupChat g) (reverse chatItems) stats + pure $ Chat Nothing (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] getGroupChatItemIdsLast_ = @@ -841,7 +841,7 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c afterChatItem <- getGroupChatItem db user groupId afterChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat (GroupChat g) chatItems stats + pure $ Chat Nothing (GroupChat g) chatItems stats where getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsAfter_ afterChatItemTs = @@ -864,7 +864,7 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat (GroupChat g) (reverse chatItems) stats + pure $ Chat Nothing (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsBefore_ beforeChatItemTs = diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 611faf90c6..010bcf2bc3 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -504,7 +504,7 @@ getProtocolServers db User {userId} = toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) = let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - in ServerCfg {server, preset, tested, enabled} + in ServerCfg {remoteHostId = Nothing, server, preset, tested, enabled} overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () overwriteProtocolServers db User {userId} servers = @@ -555,7 +555,7 @@ getCalls db = |] where toCall :: (ContactId, CallId, ChatItemId, CallState, UTCTime) -> Call - toCall (contactId, callId, chatItemId, callState, callTs) = Call {contactId, callId, chatItemId, callState, callTs} + toCall (contactId, callId, chatItemId, callState, callTs) = Call {remoteHostId = Nothing, contactId, callId, chatItemId, callState, callTs} createCommand :: DB.Connection -> User -> Maybe Int64 -> CommandFunction -> IO CommandId createCommand db User {userId} connId commandFunction = do diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index ec84860379..4127ebce4b 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -18,6 +18,7 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.RemoteControl.Types import UnliftIO +import Simplex.Chat.Types (RemoteHostId) insertRemoteHost :: DB.Connection -> Text -> FilePath -> RCHostPairing -> ExceptT StoreError IO RemoteHostId insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index af8220d8ea..78807fc038 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -316,7 +316,7 @@ userQuery = toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString) -> User toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_)) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash} + User {remoteHostId = Nothing, userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash} where profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences = userPreferences, localAlias = ""} fullPreferences = mergePreferences Nothing userPreferences diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 98d4285a2e..629054b474 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -26,7 +26,6 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent (CIContent(..), SMsgDirection (..)) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..), msgContentText) -import Simplex.Chat.Remote.Types (RemoteHostId) import Simplex.Chat.Styled import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) import Simplex.Chat.Types diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index d96bddb8b7..536384f796 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -100,11 +100,14 @@ instance FromField AgentUserId where fromField f = AgentUserId <$> fromField f instance ToField AgentUserId where toField (AgentUserId uId) = toField uId +type RemoteHostId = Int64 + aUserId :: User -> UserId aUserId User {agentUserId = AgentUserId uId} = uId data User = User - { userId :: UserId, + { remoteHostId :: Maybe RemoteHostId, + userId :: UserId, agentUserId :: AgentUserId, userContactId :: ContactId, localDisplayName :: ContactName, @@ -1521,7 +1524,8 @@ data XGrpMemIntroCont = XGrpMemIntroCont deriving (Show) data ServerCfg p = ServerCfg - { server :: ProtoServerWithAuth p, + { remoteHostId :: Maybe RemoteHostId, + server :: ProtoServerWithAuth p, preset :: Bool, tested :: Maybe Bool, enabled :: Bool diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 119d19e620..80ba9e5349 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -387,10 +387,10 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe testViewChats chats = [sShow $ map toChatView chats] where toChatView :: AChat -> (Text, Text, Maybe ConnStatus) - toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) - toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) - toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) - toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) + toChatView (AChat _ (Chat _ (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) + toChatView (AChat _ (Chat _ (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) + toChatView (AChat _ (Chat _ (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) + toChatView (AChat _ (Chat _ (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) toCIPreview :: [CChatItem c] -> Maybe GroupMember -> Text toCIPreview (ci : _) membership_ = testViewItem ci membership_ toCIPreview _ _ = "" @@ -496,7 +496,7 @@ viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack viewChats :: CurrentTime -> TimeZone -> [AChat] -> [StyledString] viewChats ts tz = concatMap chatPreview . reverse where - chatPreview (AChat _ (Chat chat items _)) = case items of + chatPreview (AChat _ (Chat _ chat items _)) = case items of CChatItem _ ci : _ -> case viewChatItem chat ci True ts tz of s : _ -> [let s' = sTake 120 s in if sLength s' < sLength s then s' <> "..." else s'] _ -> chatName From 11478da6ef54bf75424c15ff3d97c101007bec6e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 19:56:31 +0400 Subject: [PATCH 234/294] Revert "remoteHostId in backend" This reverts commit 72654caca65d8c1e5c6e886b5fd5f17a7e26e13f. --- src/Simplex/Chat.hs | 12 +- src/Simplex/Chat/Call.hs | 8 +- src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Remote.hs | 6 +- src/Simplex/Chat/Remote/Protocol.hs | 232 +--------------------------- src/Simplex/Chat/Remote/Types.hs | 4 +- src/Simplex/Chat/Store/Messages.hs | 20 +-- src/Simplex/Chat/Store/Profiles.hs | 4 +- src/Simplex/Chat/Store/Remote.hs | 1 - src/Simplex/Chat/Store/Shared.hs | 2 +- src/Simplex/Chat/Terminal/Output.hs | 1 + src/Simplex/Chat/Types.hs | 8 +- src/Simplex/Chat/View.hs | 10 +- 13 files changed, 44 insertions(+), 267 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 329285a6ec..5ec22602fd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1069,7 +1069,7 @@ processChatCommand = \case callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} (msg, _) <- sendDirectContactMessage ct (XCallInv callId invitation) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) - let call' = Call {remoteHostId = Nothing, contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} + let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) @@ -1141,7 +1141,7 @@ processChatCommand = \case rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withStore $ \db -> do user <- getUserByContactId db contactId contact <- getContact db user contactId - pure RcvCallInvitation {remoteHostId = Nothing, user, contact, callType = peerCallType, sharedKey, callTs} + pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} APIGetNetworkStatuses -> withUser $ \_ -> CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> @@ -1186,7 +1186,7 @@ processChatCommand = \case servers' = fromMaybe (L.map toServerCfg defServers) $ nonEmpty servers pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p servers' defServers where - toServerCfg server = ServerCfg {remoteHostId = Nothing, server, preset = True, tested = Nothing, enabled = True} + toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} GetUserProtoServers aProtocol -> withUser $ \User {userId} -> processChatCommand $ APIGetUserProtoServers userId aProtocol APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) -> withUserId userId $ \user -> withServerProtocol p $ @@ -4766,7 +4766,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveCallItem CISCallPending let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} - call' = Call {remoteHostId = Nothing, contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} + call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} calls <- asks currentCalls -- theoretically, the new call invitation for the current contact can mark the in-progress call as ended -- (and replace it in ChatController) @@ -4774,7 +4774,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView $ CRCallInvitation RcvCallInvitation {remoteHostId = Nothing, user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} + toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} toView $ CRNewChatItem user $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci else featureRejected CFCalls where @@ -6314,7 +6314,7 @@ chatCommandP = (Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP))) (pure Nothing) srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) - toServerCfg server = ServerCfg {remoteHostId = Nothing, server, preset = False, tested = Nothing, enabled = True} + toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} char_ = optional . A.char adminContactReq :: ConnReqContact diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 39481db4af..313442838e 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -20,15 +20,14 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import Simplex.Chat.Types (Contact, ContactId, User, RemoteHostId) +import Simplex.Chat.Types (Contact, ContactId, User) import Simplex.Chat.Types.Util (decodeJSON, encodeJSON) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) data Call = Call - { remoteHostId :: Maybe RemoteHostId, - contactId :: ContactId, + { contactId :: ContactId, callId :: CallId, chatItemId :: Int64, callState :: CallState, @@ -108,8 +107,7 @@ instance FromField CallId where fromField f = CallId <$> fromField f instance ToField CallId where toField (CallId m) = toField m data RcvCallInvitation = RcvCallInvitation - { remoteHostId :: Maybe RemoteHostId, - user :: User, + { user :: User, contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key, diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 88ad946058..9e4c309910 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -266,8 +266,7 @@ data NewChatItem d = NewChatItem -- | type to show one chat with messages data Chat c = Chat - { remoteHostId :: Maybe RemoteHostId, - chatInfo :: ChatInfo c, + { chatInfo :: ChatInfo c, chatItems :: [CChatItem c], chatStats :: ChatStats } diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index da1b0f9d78..d9ef5bd648 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -225,7 +225,7 @@ startRemoteHost rh_ = do pollEvents rhId rhClient = do oq <- asks outputQ forever $ do - r_ <- liftRH rhId $ remoteRecv rhId rhClient 10000000 + r_ <- liftRH rhId $ remoteRecv rhClient 10000000 forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) httpError :: RemoteHostId -> HTTP2ClientError -> ChatError httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow @@ -359,13 +359,13 @@ processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostClient -> ChatC processRemoteCommand remoteHostId c cmd s = case cmd of SendFile chatName f -> sendFile "/f" chatName f SendImage chatName f -> sendFile "/img" chatName f - _ -> liftRH remoteHostId $ remoteSend remoteHostId c s + _ -> liftRH remoteHostId $ remoteSend c s where sendFile cmdName chatName (CryptoFile path cfArgs) = do -- don't encrypt in host if already encrypted locally CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption - liftRH remoteHostId $ remoteSend remoteHostId c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + liftRH remoteHostId $ remoteSend c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] cryptoFileStr CryptoFile {filePath, cryptoArgs} = maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs <> encodeUtf8 (T.pack filePath) diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index e70041e9c7..c1acee1e0f 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -8,7 +8,6 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TupleSections #-} -{-# OPTIONS_GHC -Wno-ambiguous-fields #-} module Simplex.Chat.Remote.Protocol where @@ -35,12 +34,9 @@ import Data.Word (Word32) import qualified Network.HTTP.Types as N import qualified Network.HTTP2.Client as H import Network.Transport.Internal (decodeWord32, encodeWord32) -import Simplex.Chat.Call (RcvCallInvitation (..)) import Simplex.Chat.Controller -import Simplex.Chat.Messages (AChat (..), Chat (..)) import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types -import Simplex.Chat.Types (RemoteHostId, User (..), UserInfo (..), ServerCfg (..)) import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Messaging.Agent.Client (agentDRG) import qualified Simplex.Messaging.Crypto as C @@ -109,232 +105,18 @@ closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client -- ** Commands -remoteSend :: RemoteHostId -> RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse -remoteSend rhId c cmd = +remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse +remoteSend c cmd = sendRemoteCommand' c Nothing RCSend {command = decodeUtf8 cmd} >>= \case - RRChatResponse cr -> pure $ enrichCRRemoteHostId cr rhId + RRChatResponse cr -> pure cr r -> badResponse r -remoteRecv :: RemoteHostId -> RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) -remoteRecv rhId c ms = +remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) +remoteRecv c ms = sendRemoteCommand' c Nothing RCRecv {wait = ms} >>= \case - RRChatEvent cr_ -> pure $ (`enrichCRRemoteHostId` rhId) <$> cr_ + RRChatEvent cr_ -> pure cr_ r -> badResponse r -enrichCRRemoteHostId :: ChatResponse -> RemoteHostId -> ChatResponse -enrichCRRemoteHostId cr rhId = case cr of - CRActiveUser {user} -> CRActiveUser {user = enrichUser user} - CRUsersList {users} -> CRUsersList {users = map enrichUserInfo users} - CRChatStarted -> cr - CRChatRunning -> cr - CRChatStopped -> cr - CRChatSuspended -> cr - CRApiChats {user, chats} -> CRApiChats {user = enrichUser user, chats = map enrichAChat chats} - CRChats {chats} -> CRChats {chats = map enrichAChat chats} - CRApiChat {user, chat} -> CRApiChat {user = enrichUser user, chat = enrichAChat chat} - CRChatItems {user, chatName_, chatItems} -> CRChatItems {user = enrichUser user, chatName_, chatItems} - CRChatItemInfo {user, chatItem, chatItemInfo} -> CRChatItemInfo {user = enrichUser user, chatItem, chatItemInfo} - CRChatItemId user chatItemId_ -> CRChatItemId (enrichUser user) chatItemId_ - CRApiParsedMarkdown {formattedText = _ft} -> cr - CRUserProtoServers {user, servers} -> CRUserProtoServers {user = enrichUser user, servers = enrichAUserProtoServers servers} - CRServerTestResult {user, testServer, testFailure} -> CRServerTestResult {user = enrichUser user, testServer, testFailure} - CRChatItemTTL {user, chatItemTTL} -> CRChatItemTTL {user = enrichUser user, chatItemTTL} - CRNetworkConfig {networkConfig = _nc} -> cr - CRContactInfo {user, contact, connectionStats_, customUserProfile} -> CRContactInfo {user = enrichUser user, contact, connectionStats_, customUserProfile} - CRGroupInfo {user, groupInfo, groupSummary} -> CRGroupInfo {user = enrichUser user, groupInfo, groupSummary} - CRGroupMemberInfo {user, groupInfo, member, connectionStats_} -> CRGroupMemberInfo {user = enrichUser user, groupInfo, member, connectionStats_} - CRContactSwitchStarted {user, contact, connectionStats} -> CRContactSwitchStarted {user = enrichUser user, contact, connectionStats} - CRGroupMemberSwitchStarted {user, groupInfo, member, connectionStats} -> CRGroupMemberSwitchStarted {user = enrichUser user, groupInfo, member, connectionStats} - CRContactSwitchAborted {user, contact, connectionStats} -> CRContactSwitchAborted {user = enrichUser user, contact, connectionStats} - CRGroupMemberSwitchAborted {user, groupInfo, member, connectionStats} -> CRGroupMemberSwitchAborted {user = enrichUser user, groupInfo, member, connectionStats} - CRContactSwitch {user, contact, switchProgress} -> CRContactSwitch {user = enrichUser user, contact, switchProgress} - CRGroupMemberSwitch {user, groupInfo, member, switchProgress} -> CRGroupMemberSwitch {user = enrichUser user, groupInfo, member, switchProgress} - CRContactRatchetSyncStarted {user, contact, connectionStats} -> CRContactRatchetSyncStarted {user = enrichUser user, contact, connectionStats} - CRGroupMemberRatchetSyncStarted {user, groupInfo, member, connectionStats} -> CRGroupMemberRatchetSyncStarted {user = enrichUser user, groupInfo, member, connectionStats} - CRContactRatchetSync {user, contact, ratchetSyncProgress} -> CRContactRatchetSync {user = enrichUser user, contact, ratchetSyncProgress} - CRGroupMemberRatchetSync {user, groupInfo, member, ratchetSyncProgress} -> CRGroupMemberRatchetSync {user = enrichUser user, groupInfo, member, ratchetSyncProgress} - CRContactVerificationReset {user, contact} -> CRContactVerificationReset {user = enrichUser user, contact} - CRGroupMemberVerificationReset {user, groupInfo, member} -> CRGroupMemberVerificationReset {user = enrichUser user, groupInfo, member} - CRContactCode {user, contact, connectionCode} -> CRContactCode {user = enrichUser user, contact, connectionCode} - CRGroupMemberCode {user, groupInfo, member, connectionCode} -> CRGroupMemberCode {user = enrichUser user, groupInfo, member, connectionCode} - CRConnectionVerified {user, verified, expectedCode} -> CRConnectionVerified {user = enrichUser user, verified, expectedCode} - CRNewChatItem {user, chatItem} -> CRNewChatItem {user = enrichUser user, chatItem} - CRChatItemStatusUpdated {user, chatItem} -> CRChatItemStatusUpdated {user = enrichUser user, chatItem} - CRChatItemUpdated {user, chatItem} -> CRChatItemUpdated {user = enrichUser user, chatItem} - CRChatItemNotChanged {user, chatItem} -> CRChatItemNotChanged {user = enrichUser user, chatItem} - CRChatItemReaction {user, added, reaction} -> CRChatItemReaction {user = enrichUser user, added, reaction} - CRChatItemDeleted {user, deletedChatItem, toChatItem, byUser, timed} -> CRChatItemDeleted {user = enrichUser user, deletedChatItem, toChatItem, byUser, timed} - CRChatItemDeletedNotFound {user, contact, sharedMsgId} -> CRChatItemDeletedNotFound {user = enrichUser user, contact, sharedMsgId} - CRBroadcastSent {user, msgContent, successes, failures, timestamp} -> CRBroadcastSent {user = enrichUser user, msgContent, successes, failures, timestamp} - CRMsgIntegrityError {user, msgError} -> CRMsgIntegrityError {user = enrichUser user, msgError} - CRCmdAccepted {corr = _corr} -> cr - CRCmdOk {user_} -> CRCmdOk {user_ = enrichUser <$> user_} - CRChatHelp {helpSection = _hs} -> cr - CRWelcome {user} -> CRWelcome {user = enrichUser user} - CRGroupCreated {user, groupInfo} -> CRGroupCreated {user = enrichUser user, groupInfo} - CRGroupMembers {user, group} -> CRGroupMembers {user = enrichUser user, group} - CRContactsList {user, contacts} -> CRContactsList {user = enrichUser user, contacts} - CRUserContactLink {user, contactLink} -> CRUserContactLink {user = enrichUser user, contactLink} - CRUserContactLinkUpdated {user, contactLink} -> CRUserContactLinkUpdated {user = enrichUser user, contactLink} - CRContactRequestRejected {user, contactRequest} -> CRContactRequestRejected {user = enrichUser user, contactRequest} - CRUserAcceptedGroupSent {user, groupInfo, hostContact} -> CRUserAcceptedGroupSent {user = enrichUser user, groupInfo, hostContact} - CRGroupLinkConnecting {user, groupInfo, hostMember} -> CRGroupLinkConnecting {user = enrichUser user, groupInfo, hostMember} - CRUserDeletedMember {user, groupInfo, member} -> CRUserDeletedMember {user = enrichUser user, groupInfo, member} - CRGroupsList {user, groups} -> CRGroupsList {user = enrichUser user, groups} - CRSentGroupInvitation {user, groupInfo, contact, member} -> CRSentGroupInvitation {user = enrichUser user, groupInfo, contact, member} - CRFileTransferStatus user ftStatus -> CRFileTransferStatus (enrichUser user) ftStatus - CRFileTransferStatusXFTP user chatItem -> CRFileTransferStatusXFTP (enrichUser user) chatItem - CRUserProfile {user, profile} -> CRUserProfile {user = enrichUser user, profile} - CRUserProfileNoChange {user} -> CRUserProfileNoChange {user = enrichUser user} - CRUserPrivacy {user, updatedUser} -> CRUserPrivacy {user = enrichUser user, updatedUser = enrichUser updatedUser} - CRVersionInfo {versionInfo = _v, chatMigrations = _cm, agentMigrations = _am} -> cr - CRInvitation {user, connReqInvitation, connection} -> CRInvitation {user = enrichUser user, connReqInvitation, connection} - CRConnectionIncognitoUpdated {user, toConnection} -> CRConnectionIncognitoUpdated {user = enrichUser user, toConnection} - CRConnectionPlan {user, connectionPlan} -> CRConnectionPlan {user = enrichUser user, connectionPlan} - CRSentConfirmation {user} -> CRSentConfirmation {user = enrichUser user} - CRSentInvitation {user, customUserProfile} -> CRSentInvitation {user = enrichUser user, customUserProfile} - CRSentInvitationToContact {user, contact, customUserProfile} -> CRSentInvitationToContact {user = enrichUser user, contact, customUserProfile} - CRContactUpdated {user, fromContact, toContact} -> CRContactUpdated {user = enrichUser user, fromContact, toContact} - CRGroupMemberUpdated {user, groupInfo, fromMember, toMember} -> CRGroupMemberUpdated {user = enrichUser user, groupInfo, fromMember, toMember} - CRContactsMerged {user, intoContact, mergedContact, updatedContact} -> CRContactsMerged {user = enrichUser user, intoContact, mergedContact, updatedContact} - CRContactDeleted {user, contact} -> CRContactDeleted {user = enrichUser user, contact} - CRContactDeletedByContact {user, contact} -> CRContactDeletedByContact {user = enrichUser user, contact} - CRChatCleared {user, chatInfo} -> CRChatCleared {user = enrichUser user, chatInfo} - CRUserContactLinkCreated {user, connReqContact} -> CRUserContactLinkCreated {user = enrichUser user, connReqContact} - CRUserContactLinkDeleted {user} -> CRUserContactLinkDeleted {user = enrichUser user} - CRReceivedContactRequest {user, contactRequest} -> CRReceivedContactRequest {user = enrichUser user, contactRequest} - CRAcceptingContactRequest {user, contact} -> CRAcceptingContactRequest {user = enrichUser user, contact} - CRContactAlreadyExists {user, contact} -> CRContactAlreadyExists {user = enrichUser user, contact} - CRContactRequestAlreadyAccepted {user, contact} -> CRContactRequestAlreadyAccepted {user = enrichUser user, contact} - CRLeftMemberUser {user, groupInfo} -> CRLeftMemberUser {user = enrichUser user, groupInfo} - CRGroupDeletedUser {user, groupInfo} -> CRGroupDeletedUser {user = enrichUser user, groupInfo} - CRRcvFileDescrReady {user, chatItem} -> CRRcvFileDescrReady {user = enrichUser user, chatItem} - CRRcvFileAccepted {user, chatItem} -> CRRcvFileAccepted {user = enrichUser user, chatItem} - CRRcvFileAcceptedSndCancelled {user, rcvFileTransfer} -> CRRcvFileAcceptedSndCancelled {user = enrichUser user, rcvFileTransfer} - CRRcvFileDescrNotReady {user, chatItem} -> CRRcvFileDescrNotReady {user = enrichUser user, chatItem} - CRRcvFileStart {user, chatItem} -> CRRcvFileStart {user = enrichUser user, chatItem} - CRRcvFileProgressXFTP {user, chatItem, receivedSize, totalSize} -> CRRcvFileProgressXFTP {user = enrichUser user, chatItem, receivedSize, totalSize} - CRRcvFileComplete {user, chatItem} -> CRRcvFileComplete {user = enrichUser user, chatItem} - CRRcvFileCancelled {user, chatItem, rcvFileTransfer} -> CRRcvFileCancelled {user = enrichUser user, chatItem, rcvFileTransfer} - CRRcvFileSndCancelled {user, chatItem, rcvFileTransfer} -> CRRcvFileSndCancelled {user = enrichUser user, chatItem, rcvFileTransfer} - CRRcvFileError {user, chatItem, agentError} -> CRRcvFileError {user = enrichUser user, chatItem, agentError} - CRSndFileStart {user, chatItem, sndFileTransfer} -> CRSndFileStart {user = enrichUser user, chatItem, sndFileTransfer} - CRSndFileComplete {user, chatItem, sndFileTransfer} -> CRSndFileComplete {user = enrichUser user, chatItem, sndFileTransfer} - CRSndFileRcvCancelled {user, chatItem, sndFileTransfer} -> CRSndFileRcvCancelled {user = enrichUser user, chatItem, sndFileTransfer} - CRSndFileCancelled {user, chatItem, fileTransferMeta, sndFileTransfers} -> CRSndFileCancelled {user = enrichUser user, chatItem, fileTransferMeta, sndFileTransfers} - CRSndFileStartXFTP {user, chatItem, fileTransferMeta} -> CRSndFileStartXFTP {user = enrichUser user, chatItem, fileTransferMeta} - CRSndFileProgressXFTP {user, chatItem, fileTransferMeta, sentSize, totalSize} -> CRSndFileProgressXFTP {user = enrichUser user, chatItem, fileTransferMeta, sentSize, totalSize} - CRSndFileCompleteXFTP {user, chatItem, fileTransferMeta} -> CRSndFileCompleteXFTP {user = enrichUser user, chatItem, fileTransferMeta} - CRSndFileCancelledXFTP {user, chatItem, fileTransferMeta} -> CRSndFileCancelledXFTP {user = enrichUser user, chatItem, fileTransferMeta} - CRSndFileError {user, chatItem} -> CRSndFileError {user = enrichUser user, chatItem} - CRUserProfileUpdated {user, fromProfile, toProfile, updateSummary} -> CRUserProfileUpdated {user = enrichUser user, fromProfile, toProfile, updateSummary} - CRUserProfileImage {user, profile} -> CRUserProfileImage {user = enrichUser user, profile} - CRContactAliasUpdated {user, toContact} -> CRContactAliasUpdated {user = enrichUser user, toContact} - CRConnectionAliasUpdated {user, toConnection} -> CRConnectionAliasUpdated {user = enrichUser user, toConnection} - CRContactPrefsUpdated {user, fromContact, toContact} -> CRContactPrefsUpdated {user = enrichUser user, fromContact, toContact} - CRContactConnecting {user, contact} -> CRContactConnecting {user = enrichUser user, contact} - CRContactConnected {user, contact, userCustomProfile} -> CRContactConnected {user = enrichUser user, contact, userCustomProfile} - CRContactAnotherClient {user, contact} -> CRContactAnotherClient {user = enrichUser user, contact} - CRSubscriptionEnd {user, connectionEntity} -> CRSubscriptionEnd {user = enrichUser user, connectionEntity} - CRContactsDisconnected {server = _srv, contactRefs = _cr} -> cr - CRContactsSubscribed {server = _srv, contactRefs = _cr} -> cr - CRContactSubError {user, contact, chatError} -> CRContactSubError {user = enrichUser user, contact, chatError} - CRContactSubSummary {user, contactSubscriptions} -> CRContactSubSummary {user = enrichUser user, contactSubscriptions} - CRUserContactSubSummary {user, userContactSubscriptions} -> CRUserContactSubSummary {user = enrichUser user, userContactSubscriptions} - CRNetworkStatus {networkStatus = _ns, connections = _cns} -> cr - CRNetworkStatuses {user_, networkStatuses} -> CRNetworkStatuses {user_ = enrichUser <$> user_, networkStatuses} - CRHostConnected {protocol = _p, transportHost = _th} -> cr - CRHostDisconnected {protocol = _p, transportHost = _th} -> cr - CRGroupInvitation {user, groupInfo} -> CRGroupInvitation {user = enrichUser user, groupInfo} - CRReceivedGroupInvitation {user, groupInfo, contact, fromMemberRole, memberRole} -> CRReceivedGroupInvitation {user = enrichUser user, groupInfo, contact, fromMemberRole, memberRole} - CRUserJoinedGroup {user, groupInfo, hostMember} -> CRUserJoinedGroup {user = enrichUser user, groupInfo, hostMember} - CRJoinedGroupMember {user, groupInfo, member} -> CRJoinedGroupMember {user = enrichUser user, groupInfo, member} - CRJoinedGroupMemberConnecting {user, groupInfo, hostMember, member} -> CRJoinedGroupMemberConnecting {user = enrichUser user, groupInfo, hostMember, member} - CRMemberRole {user, groupInfo, byMember, member, fromRole, toRole} -> CRMemberRole {user = enrichUser user, groupInfo, byMember, member, fromRole, toRole} - CRMemberRoleUser {user, groupInfo, member, fromRole, toRole} -> CRMemberRoleUser {user = enrichUser user, groupInfo, member, fromRole, toRole} - CRConnectedToGroupMember {user, groupInfo, member, memberContact} -> CRConnectedToGroupMember {user = enrichUser user, groupInfo, member, memberContact} - CRDeletedMember {user, groupInfo, byMember, deletedMember} -> CRDeletedMember {user = enrichUser user, groupInfo, byMember, deletedMember} - CRDeletedMemberUser {user, groupInfo, member} -> CRDeletedMemberUser {user = enrichUser user, groupInfo, member} - CRLeftMember {user, groupInfo, member} -> CRLeftMember {user = enrichUser user, groupInfo, member} - CRGroupEmpty {user, groupInfo} -> CRGroupEmpty {user = enrichUser user, groupInfo} - CRGroupRemoved {user, groupInfo} -> CRGroupRemoved {user = enrichUser user, groupInfo} - CRGroupDeleted {user, groupInfo, member} -> CRGroupDeleted {user = enrichUser user, groupInfo, member} - CRGroupUpdated {user, fromGroup, toGroup, member_} -> CRGroupUpdated {user = enrichUser user, fromGroup, toGroup, member_} - CRGroupProfile {user, groupInfo} -> CRGroupProfile {user = enrichUser user, groupInfo} - CRGroupDescription {user, groupInfo} -> CRGroupDescription {user = enrichUser user, groupInfo} - CRGroupLinkCreated {user, groupInfo, connReqContact, memberRole} -> CRGroupLinkCreated {user = enrichUser user, groupInfo, connReqContact, memberRole} - CRGroupLink {user, groupInfo, connReqContact, memberRole} -> CRGroupLink {user = enrichUser user, groupInfo, connReqContact, memberRole} - CRGroupLinkDeleted {user, groupInfo} -> CRGroupLinkDeleted {user = enrichUser user, groupInfo} - CRAcceptingGroupJoinRequest {user, groupInfo, contact} -> CRAcceptingGroupJoinRequest {user = enrichUser user, groupInfo, contact} - CRAcceptingGroupJoinRequestMember {user, groupInfo, member} -> CRAcceptingGroupJoinRequestMember {user = enrichUser user, groupInfo, member} - CRNoMemberContactCreating {user, groupInfo, member} -> CRNoMemberContactCreating {user = enrichUser user, groupInfo, member} - CRNewMemberContact {user, contact, groupInfo, member} -> CRNewMemberContact {user = enrichUser user, contact, groupInfo, member} - CRNewMemberContactSentInv {user, contact, groupInfo, member} -> CRNewMemberContactSentInv {user = enrichUser user, contact, groupInfo, member} - CRNewMemberContactReceivedInv {user, contact, groupInfo, member} -> CRNewMemberContactReceivedInv {user = enrichUser user, contact, groupInfo, member} - CRContactAndMemberAssociated {user, contact, groupInfo, member, updatedContact} -> CRContactAndMemberAssociated {user = enrichUser user, contact, groupInfo, member, updatedContact} - CRMemberSubError {user, groupInfo, member, chatError} -> CRMemberSubError {user = enrichUser user, groupInfo, member, chatError} - CRMemberSubSummary {user, memberSubscriptions} -> CRMemberSubSummary {user = enrichUser user, memberSubscriptions} - CRGroupSubscribed {user, groupInfo} -> CRGroupSubscribed {user = enrichUser user, groupInfo} - CRPendingSubSummary {user, pendingSubscriptions} -> CRPendingSubSummary {user = enrichUser user, pendingSubscriptions} - CRSndFileSubError {user, sndFileTransfer, chatError} -> CRSndFileSubError {user = enrichUser user, sndFileTransfer, chatError} - CRRcvFileSubError {user, rcvFileTransfer, chatError} -> CRRcvFileSubError {user = enrichUser user, rcvFileTransfer, chatError} - CRCallInvitation {callInvitation} -> CRCallInvitation {callInvitation = enrichRcvCallInvitation callInvitation} - CRCallOffer {user, contact, callType, offer, sharedKey, askConfirmation} -> CRCallOffer {user = enrichUser user, contact, callType, offer, sharedKey, askConfirmation} - CRCallAnswer {user, contact, answer} -> CRCallAnswer {user = enrichUser user, contact, answer} - CRCallExtraInfo {user, contact, extraInfo} -> CRCallExtraInfo {user = enrichUser user, contact, extraInfo} - CRCallEnded {user, contact} -> CRCallEnded {user = enrichUser user, contact} - CRCallInvitations {callInvitations} -> CRCallInvitations {callInvitations = map enrichRcvCallInvitation callInvitations} - CRUserContactLinkSubscribed -> cr - CRUserContactLinkSubError {chatError = _ce} -> cr - CRNtfTokenStatus {status = _s} -> cr - CRNtfToken {token = _t, status = _s, ntfMode = _nm} -> cr - CRNtfMessages {user_, connEntity, msgTs, ntfMessages} -> CRNtfMessages {user_ = enrichUser <$> user_, connEntity, msgTs, ntfMessages} - CRNewContactConnection {user, connection} -> CRNewContactConnection {user = enrichUser user, connection} - CRContactConnectionDeleted {user, connection} -> CRContactConnectionDeleted {user = enrichUser user, connection} - CRRemoteHostList {remoteHosts = _rh} -> cr - CRCurrentRemoteHost {remoteHost_ = _rh} -> cr - CRRemoteHostStarted {remoteHost_ = _rh, invitation = _i} -> cr - CRRemoteHostSessionCode {remoteHost_ = _rh, sessionCode = _sc} -> cr - CRNewRemoteHost {remoteHost = _rh} -> cr - CRRemoteHostConnected {remoteHost = _rh} -> cr - CRRemoteHostStopped {remoteHostId_ = _rh} -> cr - CRRemoteFileStored {remoteHostId = _rhId, remoteFileSource = _rfs} -> cr - CRRemoteCtrlList {remoteCtrls = _rc} -> cr - CRRemoteCtrlFound {remoteCtrl = _rc} -> cr - CRRemoteCtrlConnecting {remoteCtrl_ = _rc, ctrlAppInfo = _cai, appVersion = _av} -> cr - CRRemoteCtrlSessionCode {remoteCtrl_ = _rc, sessionCode = _sc} -> cr - CRRemoteCtrlConnected {remoteCtrl = _rc} -> cr - CRRemoteCtrlStopped -> cr - CRSQLResult {rows = _r} -> cr - CRSlowSQLQueries {chatQueries = _cq, agentQueries = _aq} -> cr - CRDebugLocks {chatLockName = _cl, agentLocks = _al} -> cr - CRAgentStats {agentStats = _as} -> cr - CRAgentSubs {activeSubs = _as, pendingSubs = _ps, removedSubs = _rs} -> cr - CRAgentSubsDetails {agentSubs = _as} -> cr - CRConnectionDisabled {connectionEntity = _ce} -> cr - CRAgentRcvQueueDeleted {agentConnId = _acId, server = _srv, agentQueueId = _aqId, agentError_ = _ae} -> cr - CRAgentConnDeleted {agentConnId = _acId} -> cr - CRAgentUserDeleted {agentUserId = _auId} -> cr - CRMessageError {user, severity, errorMessage} -> CRMessageError {user = enrichUser user, severity, errorMessage} - CRChatCmdError {user_, chatError} -> CRChatCmdError {user_ = enrichUser <$> user_, chatError} - CRChatError {user_, chatError} -> CRChatError {user_ = enrichUser <$> user_, chatError} - CRArchiveImported {archiveErrors = _ae} -> cr - CRTimedAction {action = _a, durationMilliseconds = _dm} -> cr - where - enrichUser :: User -> User - enrichUser u = u {remoteHostId = Just rhId} - enrichUserInfo :: UserInfo -> UserInfo - enrichUserInfo uInfo@UserInfo {user} = uInfo {user = enrichUser user} - enrichAChat :: AChat -> AChat - enrichAChat (AChat cType chat) = AChat cType chat {remoteHostId = Just rhId} - enrichServerCfg :: ServerCfg p -> ServerCfg p - enrichServerCfg cfg = cfg {remoteHostId = Just rhId} - enrichAUserProtoServers :: AUserProtoServers -> AUserProtoServers - enrichAUserProtoServers (AUPS ups@UserProtoServers {protoServers}) = AUPS ups {protoServers = fmap enrichServerCfg protoServers} - enrichRcvCallInvitation :: RcvCallInvitation -> RcvCallInvitation - enrichRcvCallInvitation rci = rci {remoteHostId = Just rhId} - - remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath remoteStoreFile c localPath fileName = do (fileSize, fileDigest) <- getFileInfo localPath @@ -358,7 +140,7 @@ sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do - encFile_ <- mapM (prepareEncryptedFile encryption) file_ + encFile_ <- mapM (prepareEncryptedFile encryption) file_ req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd) HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing (header, getNext) <- parseDecryptHTTP2Body encryption response respBody diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 12b5862813..783a083e55 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -19,7 +19,7 @@ import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) import Simplex.Chat.Remote.AppVersion -import Simplex.Chat.Types (verificationCode, RemoteHostId) +import Simplex.Chat.Types (verificationCode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) @@ -118,6 +118,8 @@ data RemoteProtocolError | RPEException {someException :: Text} deriving (Show, Exception) +type RemoteHostId = Int64 + data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} deriving (Eq, Ord, Show) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 859ce638e0..b6d455fe86 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -572,7 +572,7 @@ getDirectChatPreviews_ db user@User {userId} = do let contact = toContact user $ contactRow :. connRow ci_ = toDirectChatItemList currentTs ciRow_ stats = toChatStats statsRow - in AChat SCTDirect $ Chat Nothing (DirectChat contact) ci_ stats + in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] getGroupChatPreviews_ db User {userId, userContactId} = do @@ -645,7 +645,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do let groupInfo = toGroupInfo userContactId groupInfoRow ci_ = toGroupChatItemList currentTs userContactId ciRow_ stats = toChatStats statsRow - in AChat SCTGroup $ Chat Nothing (GroupChat groupInfo) ci_ stats + in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats getContactRequestChatPreviews_ :: DB.Connection -> User -> IO [AChat] getContactRequestChatPreviews_ db User {userId} = @@ -669,7 +669,7 @@ getContactRequestChatPreviews_ db User {userId} = toContactRequestChatPreview cReqRow = let cReq = toContactRequest cReqRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactRequest $ Chat Nothing (ContactRequest cReq) [] stats + in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats getContactConnectionChatPreviews_ :: DB.Connection -> User -> Bool -> IO [AChat] getContactConnectionChatPreviews_ _ _ False = pure [] @@ -688,7 +688,7 @@ getContactConnectionChatPreviews_ db User {userId} _ = toContactConnectionChatPreview connRow = let conn = toPendingContactConnection connRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - in AChat SCTContactConnection $ Chat Nothing (ContactConnection conn) [] stats + in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChat db user contactId pagination search_ = do @@ -703,7 +703,7 @@ getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> Excep getDirectChatLast_ db user ct@Contact {contactId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- getDirectChatItemsLast db user contactId count search - pure $ Chat Nothing (DirectChat ct) (reverse chatItems) stats + pure $ Chat (DirectChat ct) (reverse chatItems) stats -- the last items in reverse order (the last item in the conversation is the first in the returned list) getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect] @@ -733,7 +733,7 @@ getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsAfter_ - pure $ Chat Nothing (DirectChat ct) chatItems stats + pure $ Chat (DirectChat ct) chatItems stats where getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsAfter_ = do @@ -763,7 +763,7 @@ getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsBefore_ - pure $ Chat Nothing (DirectChat ct) (reverse chatItems) stats + pure $ Chat (DirectChat ct) (reverse chatItems) stats where getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsBefore_ = do @@ -803,7 +803,7 @@ getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItemIds <- liftIO getGroupChatItemIdsLast_ chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat Nothing (GroupChat g) (reverse chatItems) stats + pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] getGroupChatItemIdsLast_ = @@ -841,7 +841,7 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c afterChatItem <- getGroupChatItem db user groupId afterChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat Nothing (GroupChat g) chatItems stats + pure $ Chat (GroupChat g) chatItems stats where getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsAfter_ afterChatItemTs = @@ -864,7 +864,7 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds - pure $ Chat Nothing (GroupChat g) (reverse chatItems) stats + pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsBefore_ beforeChatItemTs = diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 010bcf2bc3..611faf90c6 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -504,7 +504,7 @@ getProtocolServers db User {userId} = toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) = let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - in ServerCfg {remoteHostId = Nothing, server, preset, tested, enabled} + in ServerCfg {server, preset, tested, enabled} overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () overwriteProtocolServers db User {userId} servers = @@ -555,7 +555,7 @@ getCalls db = |] where toCall :: (ContactId, CallId, ChatItemId, CallState, UTCTime) -> Call - toCall (contactId, callId, chatItemId, callState, callTs) = Call {remoteHostId = Nothing, contactId, callId, chatItemId, callState, callTs} + toCall (contactId, callId, chatItemId, callState, callTs) = Call {contactId, callId, chatItemId, callState, callTs} createCommand :: DB.Connection -> User -> Maybe Int64 -> CommandFunction -> IO CommandId createCommand db User {userId} connId commandFunction = do diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 4127ebce4b..ec84860379 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -18,7 +18,6 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.RemoteControl.Types import UnliftIO -import Simplex.Chat.Types (RemoteHostId) insertRemoteHost :: DB.Connection -> Text -> FilePath -> RCHostPairing -> ExceptT StoreError IO RemoteHostId insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 78807fc038..af8220d8ea 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -316,7 +316,7 @@ userQuery = toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString) -> User toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_)) = - User {remoteHostId = Nothing, userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash} + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash} where profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences = userPreferences, localAlias = ""} fullPreferences = mergePreferences Nothing userPreferences diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 629054b474..98d4285a2e 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -26,6 +26,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent (CIContent(..), SMsgDirection (..)) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..), msgContentText) +import Simplex.Chat.Remote.Types (RemoteHostId) import Simplex.Chat.Styled import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) import Simplex.Chat.Types diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 536384f796..d96bddb8b7 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -100,14 +100,11 @@ instance FromField AgentUserId where fromField f = AgentUserId <$> fromField f instance ToField AgentUserId where toField (AgentUserId uId) = toField uId -type RemoteHostId = Int64 - aUserId :: User -> UserId aUserId User {agentUserId = AgentUserId uId} = uId data User = User - { remoteHostId :: Maybe RemoteHostId, - userId :: UserId, + { userId :: UserId, agentUserId :: AgentUserId, userContactId :: ContactId, localDisplayName :: ContactName, @@ -1524,8 +1521,7 @@ data XGrpMemIntroCont = XGrpMemIntroCont deriving (Show) data ServerCfg p = ServerCfg - { remoteHostId :: Maybe RemoteHostId, - server :: ProtoServerWithAuth p, + { server :: ProtoServerWithAuth p, preset :: Bool, tested :: Maybe Bool, enabled :: Bool diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 80ba9e5349..119d19e620 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -387,10 +387,10 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe testViewChats chats = [sShow $ map toChatView chats] where toChatView :: AChat -> (Text, Text, Maybe ConnStatus) - toChatView (AChat _ (Chat _ (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) - toChatView (AChat _ (Chat _ (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) - toChatView (AChat _ (Chat _ (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) - toChatView (AChat _ (Chat _ (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) + toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) + toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) + toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) + toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) toCIPreview :: [CChatItem c] -> Maybe GroupMember -> Text toCIPreview (ci : _) membership_ = testViewItem ci membership_ toCIPreview _ _ = "" @@ -496,7 +496,7 @@ viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack viewChats :: CurrentTime -> TimeZone -> [AChat] -> [StyledString] viewChats ts tz = concatMap chatPreview . reverse where - chatPreview (AChat _ (Chat _ chat items _)) = case items of + chatPreview (AChat _ (Chat chat items _)) = case items of CChatItem _ ci : _ -> case viewChatItem chat ci True ts tz of s : _ -> [let s' = sTake 120 s in if sLength s' < sLength s then s' <> "..." else s'] _ -> chatName From f6b786a187f52a44b70cb70e8ad6a5c26d38277e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Nov 2023 20:31:35 +0400 Subject: [PATCH 235/294] get user index by remote host id --- .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 179a4e846a..660e7de151 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -122,7 +122,7 @@ object ChatModel { } private fun getUserIndex(user: User): Int = - users.indexOfFirst { it.user.userId == user.userId } + users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == user.remoteHostId } fun updateUser(user: User) { val i = getUserIndex(user) From 7f5efd8927c4ca1d4d5b8bd63aa6a5a347486b01 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 20:56:05 +0000 Subject: [PATCH 236/294] desktop: use correct remote host when creating/connecting via links (#3409) --- .../views/newchat/ConnectViaLinkView.android.kt | 7 ++++--- .../views/newchat/ScanToConnectView.android.kt | 5 +++-- .../simplex/common/views/chatlist/ChatListView.kt | 2 +- .../simplex/common/views/newchat/AddContactView.kt | 12 ++++++------ .../simplex/common/views/newchat/AddGroupView.kt | 3 ++- .../common/views/newchat/ConnectViaLinkView.kt | 3 ++- .../simplex/common/views/newchat/CreateLinkView.kt | 11 +++++------ .../simplex/common/views/newchat/NewChatSheet.kt | 8 ++++---- .../simplex/common/views/newchat/PasteToConnect.kt | 13 ++++++------- .../common/views/newchat/ScanToConnectView.kt | 8 ++++---- .../views/newchat/ConnectViaLinkView.desktop.kt | 5 +++-- .../views/newchat/ScanToConnectView.desktop.kt | 5 +++-- 12 files changed, 43 insertions(+), 39 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt index 1faf115b37..e5a7ae40a5 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt @@ -9,10 +9,11 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo import chat.simplex.res.MR @Composable -actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { +actual fun ConnectViaLinkView(m: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { // TODO this should close if remote host changes in model val selection = remember { mutableStateOf( @@ -32,10 +33,10 @@ actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { Column(Modifier.weight(1f)) { when (selection.value) { ConnectViaLinkTab.SCAN -> { - ScanToConnectView(m, rhId, close) + ScanToConnectView(m, rh, close) } ConnectViaLinkTab.PASTE -> { - PasteToConnectView(m, rhId, close) + PasteToConnectView(m, rh, close) } } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt index 89477e45a1..f046f44bee 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt @@ -4,17 +4,18 @@ import android.Manifest import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() } ConnectContactLayout( chatModel = chatModel, - rhId = rhId, + rh = rh, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 1644d286f5..f9502bf908 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -118,7 +118,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf if (searchInList.isEmpty()) { DesktopActiveCallOverlayLayout(newChatSheetState) // TODO disable this button and sheet for the duration of the switch - NewChatSheet(chatModel, chatModel.remoteHostId, newChatSheetState, stopped, hideNewChatSheet) + NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index 360667fcfb..a539bf4984 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -25,13 +25,13 @@ import chat.simplex.res.MR @Composable fun AddContactView( chatModel: ChatModel, - rhId: Long?, + rh: RemoteHostInfo?, connReqInvitation: String, contactConnection: MutableState ) { val clipboard = LocalClipboardManager.current AddContactLayout( - rhId = rhId, + rh = rh, chatModel = chatModel, incognitoPref = chatModel.controller.appPrefs.incognito, connReq = connReqInvitation, @@ -54,7 +54,7 @@ fun AddContactView( @Composable fun AddContactLayout( chatModel: ChatModel, - rhId: Long?, + rh: RemoteHostInfo?, incognitoPref: SharedPreference, connReq: String, contactConnection: MutableState, @@ -66,9 +66,9 @@ fun AddContactLayout( withApi { val contactConnVal = contactConnection.value if (contactConnVal != null) { - chatModel.controller.apiSetConnectionIncognito(rhId, contactConnVal.pccConnId, incognito.value)?.let { + chatModel.controller.apiSetConnectionIncognito(rh?.remoteHostId, contactConnVal.pccConnId, incognito.value)?.let { contactConnection.value = it - chatModel.updateContactConnection(rhId, it) + chatModel.updateContactConnection(rh?.remoteHostId, it) } } } @@ -175,7 +175,7 @@ fun sharedProfileInfo( fun PreviewAddContactView() { SimpleXTheme { AddContactLayout( - rhId = null, + rh = null, chatModel = ChatModel, incognitoPref = SharedPreference({ false }, {}), connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index d60ee75311..aa5494e5a9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -32,7 +32,8 @@ import kotlinx.coroutines.launch import java.net.URI @Composable -fun AddGroupView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { +fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { + val rhId = rh?.remoteHostId AddGroupLayout( createGroup = { incognito, groupProfile -> withApi { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt index e41c8701e3..0077e2849c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt @@ -2,10 +2,11 @@ package chat.simplex.common.views.newchat import androidx.compose.runtime.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo enum class ConnectViaLinkTab { SCAN, PASTE } @Composable -expect fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) +expect fun ConnectViaLinkView(m: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt index f4252f53b0..0a23ed743d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt @@ -9,8 +9,7 @@ import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.PendingContactConnection +import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.UserAddressView import chat.simplex.res.MR @@ -20,7 +19,7 @@ enum class CreateLinkTab { } @Composable -fun CreateLinkView(m: ChatModel, rhId: Long?, initialSelection: CreateLinkTab) { +fun CreateLinkView(m: ChatModel, rh: RemoteHostInfo?, initialSelection: CreateLinkTab) { val selection = remember { mutableStateOf(initialSelection) } val connReqInvitation = rememberSaveable { m.connReqInv } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) } @@ -32,7 +31,7 @@ fun CreateLinkView(m: ChatModel, rhId: Long?, initialSelection: CreateLinkTab) { && contactConnection.value == null && !creatingConnReq.value ) { - createInvitation(m, rhId, creatingConnReq, connReqInvitation, contactConnection) + createInvitation(m, rh?.remoteHostId, creatingConnReq, connReqInvitation, contactConnection) } } /** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. @@ -65,10 +64,10 @@ fun CreateLinkView(m: ChatModel, rhId: Long?, initialSelection: CreateLinkTab) { Column(Modifier.weight(1f)) { when (selection.value) { CreateLinkTab.ONE_TIME -> { - AddContactView(m, rhId,connReqInvitation.value ?: "", contactConnection) + AddContactView(m, rh,connReqInvitation.value ?: "", contactConnection) } CreateLinkTab.LONG_TERM -> { - UserAddressView(m, rhId, viaCreateLinkView = true, close = {}) + UserAddressView(m, rh?.remoteHostId, viaCreateLinkView = true, close = {}) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 382bc72e4f..86929584c1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -33,7 +33,7 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt @Composable -fun NewChatSheet(chatModel: ChatModel, rhId: Long?, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { +fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { // TODO close new chat if remote host changes in model if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } NewChatSheetLayout( @@ -42,17 +42,17 @@ fun NewChatSheet(chatModel: ChatModel, rhId: Long?, newChatSheetState: StateFlow addContact = { closeNewChatSheet(false) ModalManager.center.closeModals() - ModalManager.center.showModal { CreateLinkView(chatModel, rhId, CreateLinkTab.ONE_TIME) } + ModalManager.center.showModal { CreateLinkView(chatModel, chatModel.currentRemoteHost.value, CreateLinkTab.ONE_TIME) } }, connectViaLink = { closeNewChatSheet(false) ModalManager.center.closeModals() - ModalManager.center.showModalCloseable { close -> ConnectViaLinkView(chatModel, rhId, close) } + ModalManager.center.showModalCloseable { close -> ConnectViaLinkView(chatModel, chatModel.currentRemoteHost.value, close) } }, createGroup = { closeNewChatSheet(false) ModalManager.center.closeModals() - ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, rhId, close) } + ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close) } }, closeNewChatSheet, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index d40fa97624..3e4447d355 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -14,8 +14,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.SharedPreference +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.IncognitoView @@ -24,12 +23,12 @@ import chat.simplex.res.MR import java.net.URI @Composable -fun PasteToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { +fun PasteToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { val connectionLink = remember { mutableStateOf("") } val clipboard = LocalClipboardManager.current PasteToConnectLayout( chatModel = chatModel, - rhId = rhId, + rh = rh, incognitoPref = chatModel.controller.appPrefs.incognito, connectionLink = connectionLink, pasteFromClipboard = { @@ -42,14 +41,14 @@ fun PasteToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { @Composable fun PasteToConnectLayout( chatModel: ChatModel, - rhId: Long?, + rh: RemoteHostInfo?, incognitoPref: SharedPreference, connectionLink: MutableState, pasteFromClipboard: () -> Unit, close: () -> Unit ) { val incognito = remember { mutableStateOf(incognitoPref.get()) } - + val rhId = rh?.remoteHostId fun connectViaLink(connReqUri: String) { try { val uri = URI(connReqUri) @@ -126,7 +125,7 @@ fun PreviewPasteToConnectTextbox() { SimpleXTheme { PasteToConnectLayout( chatModel = ChatModel, - rhId = null, + rh = null, incognitoPref = SharedPreference({ false }, {}), connectionLink = remember { mutableStateOf("") }, pasteFromClipboard = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 523b7e5327..bd111c9c32 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -26,7 +26,7 @@ import chat.simplex.res.MR import java.net.URI @Composable -expect fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) +expect fun ScanToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) enum class ConnectionLinkType { INVITATION, CONTACT, GROUP @@ -428,7 +428,7 @@ fun openKnownGroup(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, grou @Composable fun ConnectContactLayout( chatModel: ChatModel, - rhId: Long?, + rh: RemoteHostInfo?, incognitoPref: SharedPreference, close: () -> Unit ) { @@ -440,7 +440,7 @@ fun ConnectContactLayout( try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, rhId, uri, incognito = incognito.value, close) + planAndConnect(chatModel, rh?.remoteHostId, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -492,7 +492,7 @@ fun PreviewConnectContactLayout() { SimpleXTheme { ConnectContactLayout( chatModel = ChatModel, - rhId = null, + rh = null, incognitoPref = SharedPreference({ false }, {}), close = {}, ) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt index 6d56a7b515..72d9678154 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt @@ -2,9 +2,10 @@ package chat.simplex.common.views.newchat import androidx.compose.runtime.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo @Composable -actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { +actual fun ConnectViaLinkView(m: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { // TODO this should close if remote host changes in model - PasteToConnectView(m, rhId, close) + PasteToConnectView(m, rh, close) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt index 540de40a95..7579f09fa5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt @@ -2,12 +2,13 @@ package chat.simplex.common.views.newchat import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.RemoteHostInfo @Composable -actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { ConnectContactLayout( chatModel = chatModel, - rhId = rhId, + rh = rh, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) From 121985138aacd4e09d6e1ae898ce95940d8619d6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 22:24:19 +0000 Subject: [PATCH 237/294] ios: export localizations --- .../bg.xcloc/Localized Contents/bg.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../cs.xcloc/Localized Contents/cs.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../de.xcloc/Localized Contents/de.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../en.xcloc/Localized Contents/en.xliff | 150 ++++++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../es.xcloc/Localized Contents/es.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../fi.xcloc/Localized Contents/fi.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../fr.xcloc/Localized Contents/fr.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../it.xcloc/Localized Contents/it.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../ja.xcloc/Localized Contents/ja.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../nl.xcloc/Localized Contents/nl.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../pl.xcloc/Localized Contents/pl.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../ru.xcloc/Localized Contents/ru.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../th.xcloc/Localized Contents/th.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../uk.xcloc/Localized Contents/uk.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../Localized Contents/zh-Hans.xliff | 120 ++++++++++++++ .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + 30 files changed, 1860 insertions(+) diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 77133907fb..19e311468a 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ Назад No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Лошо ID на съобщението @@ -1151,6 +1163,10 @@ Свързване инкогнито No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Свързване със сървъра… @@ -1198,6 +1222,10 @@ This is your own one-time link! Свързване със сървър…(грешка: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Връзка @@ -1218,6 +1246,10 @@ This is your own one-time link! Заявката за връзка е изпратена! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Времето на изчакване за установяване на връзката изтече @@ -1701,6 +1733,18 @@ This cannot be undone! Описание No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Разработване @@ -1791,11 +1835,19 @@ This cannot be undone! Прекъсни връзката server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Открийте и се присъединете към групи No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. НЕ използвайте SimpleX за спешни повиквания. @@ -2003,6 +2055,10 @@ This cannot be undone! Въведи сървъра ръчно No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Въведи съобщение при посрещане… @@ -2773,6 +2829,10 @@ This cannot be undone! Несъвместима версия на базата данни No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Неправилен kод за достъп @@ -2938,6 +2998,10 @@ This is your link for group %@! Присъединяване към групата No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Запазете връзките си @@ -2998,6 +3062,14 @@ This is your link for group %@! Ограничения No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Съобщение на живо! @@ -3565,6 +3637,10 @@ This is your link for group %@! Постави No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Постави изображение @@ -4156,6 +4232,10 @@ This is your link for group %@! Сканирай QR код No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Сканирай код @@ -4376,6 +4456,10 @@ This is your link for group %@! Сървъри No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Задай 1 ден @@ -4872,6 +4956,10 @@ It can happen because of some bug or when the connection is compromised.Това действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Тази група има над %lld членове, потвърждения за доставка не се изпращат. @@ -5061,6 +5149,14 @@ To connect, please ask your contact to create another connection link and check За да се свържете, моля, помолете вашия контакт да създаде друг линк за връзка и проверете дали имате стабилна мрежова връзка. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Отключи @@ -5151,6 +5247,10 @@ To connect, please ask your contact to create another connection link and check Използвай за нови връзки No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Използвай интерфейса за повикване на iOS @@ -5181,11 +5281,23 @@ To connect, please ask your contact to create another connection link and check Използват се сървърите на SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Потвръди сигурността на връзката No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Потвръди кода за сигурност @@ -6356,6 +6468,10 @@ SimpleX сървърите не могат да видят вашия профи актуализиран профил на групата rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6493,6 +6609,10 @@ SimpleX сървърите не могат да видят вашия профи SimpleX използва Face ID за локалнa идентификация Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX се нуждае от достъп до микрофона за аудио и видео разговори и за запис на гласови съобщения. diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/bg.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index fe78e2da82..0286c8c684 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ Zpět No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Špatné ID zprávy @@ -1151,6 +1163,10 @@ Spojit se inkognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Připojování k serveru… @@ -1198,6 +1222,10 @@ This is your own one-time link! Připojování k serveru... (chyba: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Připojení @@ -1218,6 +1246,10 @@ This is your own one-time link! Požadavek na připojení byl odeslán! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Časový limit připojení @@ -1701,6 +1733,18 @@ This cannot be undone! Popis No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Vyvinout @@ -1791,11 +1835,19 @@ This cannot be undone! Odpojit server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Objevte a připojte skupiny No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. NEpoužívejte SimpleX pro tísňová volání. @@ -2003,6 +2055,10 @@ This cannot be undone! Zadejte server ručně No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Zadat uvítací zprávu… @@ -2773,6 +2829,10 @@ This cannot be undone! Nekompatibilní verze databáze No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Nesprávné heslo @@ -2938,6 +2998,10 @@ This is your link for group %@! Připojování ke skupině No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Zachovat vaše připojení @@ -2998,6 +3062,14 @@ This is your link for group %@! Omezení No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Živé zprávy! @@ -3565,6 +3637,10 @@ This is your link for group %@! Vložit No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Vložit obrázek @@ -4156,6 +4232,10 @@ This is your link for group %@! Skenovat QR kód No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Skenovat kód @@ -4376,6 +4456,10 @@ This is your link for group %@! Servery No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Nastavit 1 den @@ -4872,6 +4956,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Tuto akci nelze vzít zpět - váš profil, kontakty, zprávy a soubory budou nenávratně ztraceny. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Tato skupina má více než %lld členů, potvrzení o doručení nejsou odesílány. @@ -5061,6 +5149,14 @@ To connect, please ask your contact to create another connection link and check Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu na připojení a zkontrolujte, zda máte stabilní připojení k síti. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Odemknout @@ -5151,6 +5247,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Použít pro nová připojení No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Použít rozhraní volání iOS @@ -5181,11 +5281,23 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Používat servery SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Ověření zabezpečení připojení No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Ověření bezpečnostního kódu @@ -6355,6 +6467,10 @@ Servery SimpleX nevidí váš profil. aktualizoval profil skupiny rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6492,6 +6608,10 @@ Servery SimpleX nevidí váš profil. SimpleX používá Face ID pro místní ověřování Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX potřebuje přístup k mikrofonu pro audio a video hovory a pro nahrávání hlasových zpráv. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 259d7c12f2..a69ae55fd3 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Zurück No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Falsche Nachrichten-ID @@ -1165,6 +1177,10 @@ Inkognito verbinden No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Mit Ihnen selbst verbinden? @@ -1209,6 +1225,14 @@ Das ist Ihr eigener Einmal-Link! Mit %@ verbinden No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Mit dem Server verbinden… @@ -1219,6 +1243,10 @@ Das ist Ihr eigener Einmal-Link! Mit dem Server verbinden… (Fehler: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Verbindung @@ -1239,6 +1267,10 @@ Das ist Ihr eigener Einmal-Link! Verbindungsanfrage wurde gesendet! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Verbindungszeitüberschreitung @@ -1729,6 +1761,18 @@ Das kann nicht rückgängig gemacht werden! Beschreibung No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Entwicklung @@ -1819,11 +1863,19 @@ Das kann nicht rückgängig gemacht werden! Trennen server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Gruppen entdecken und ihnen beitreten No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Nutzen Sie SimpleX nicht für Notrufe. @@ -2032,6 +2084,10 @@ Das kann nicht rückgängig gemacht werden! Geben Sie den Server manuell ein No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Geben Sie eine Begrüßungsmeldung ein … @@ -2807,6 +2863,10 @@ Das kann nicht rückgängig gemacht werden! Inkompatible Datenbank-Version No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Zugangscode ist falsch @@ -2977,6 +3037,10 @@ Das ist Ihr Link für die Gruppe %@! Der Gruppe beitreten No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Ihre Verbindungen beibehalten @@ -3037,6 +3101,14 @@ Das ist Ihr Link für die Gruppe %@! Einschränkungen No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Live Nachricht! @@ -3606,6 +3678,10 @@ Das ist Ihr Link für die Gruppe %@! Einfügen No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Bild einfügen @@ -4201,6 +4277,10 @@ Das ist Ihr Link für die Gruppe %@! QR-Code scannen No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Code scannen @@ -4421,6 +4501,10 @@ Das ist Ihr Link für die Gruppe %@! Server No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Einen Tag festlegen @@ -4918,6 +5002,10 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Es werden keine Empfangsbestätigungen gesendet, da diese Gruppe über %lld Mitglieder hat. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um sich neu verbinden zu können und stellen Sie sicher, dass Sie eine stabile Netzwerk-Verbindung haben. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Entsperren @@ -5202,6 +5298,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Für neue Verbindungen nutzen No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface iOS Anrufschnittstelle nutzen @@ -5232,11 +5332,23 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verwendung von SimpleX-Chat-Servern. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Sicherheit der Verbindung überprüfen No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Sicherheitscode überprüfen @@ -6424,6 +6536,10 @@ SimpleX-Server können Ihr Profil nicht einsehen. Aktualisiertes Gruppenprofil rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ SimpleX-Server können Ihr Profil nicht einsehen. Face ID wird von SimpleX für die lokale Authentifizierung genutzt Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX benötigt Zugriff auf das Mikrofon, um Audio- und Videoanrufe und die Aufnahme von Sprachnachrichten zu ermöglichen. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/de.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index aa128d59d9..dd302cd1e1 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -297,6 +297,16 @@ ( No comment provided by engineer. + + (new) + (new) + No comment provided by engineer. + + + (this device v%@) + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +879,11 @@ Back No comment provided by engineer. + + Bad desktop address + Bad desktop address + No comment provided by engineer. + Bad message ID Bad message ID @@ -1165,6 +1180,11 @@ Connect incognito No comment provided by engineer. + + Connect to desktop + Connect to desktop + No comment provided by engineer. + Connect to yourself? Connect to yourself? @@ -1209,6 +1229,16 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + Connected desktop + No comment provided by engineer. + + + Connected to desktop + Connected to desktop + No comment provided by engineer. + Connecting to server… Connecting to server… @@ -1219,6 +1249,11 @@ This is your own one-time link! Connecting to server… (error: %@) No comment provided by engineer. + + Connecting to desktop + Connecting to desktop + No comment provided by engineer. + Connection Connection @@ -1239,6 +1274,11 @@ This is your own one-time link! Connection request sent! No comment provided by engineer. + + Connection terminated + Connection terminated + No comment provided by engineer. + Connection timeout Connection timeout @@ -1729,6 +1769,21 @@ This cannot be undone! Description No comment provided by engineer. + + Desktop address + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + Desktop devices + No comment provided by engineer. + Develop Develop @@ -1819,11 +1874,21 @@ This cannot be undone! Disconnect server test step + + Disconnect desktop? + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Discover and join groups No comment provided by engineer. + + Discover on network + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Do NOT use SimpleX for emergency calls. @@ -2034,6 +2099,11 @@ This cannot be undone! Enter server manually No comment provided by engineer. + + Enter this device name… + Enter this device name… + No comment provided by engineer. + Enter welcome message… Enter welcome message… @@ -2809,6 +2879,11 @@ This cannot be undone! Incompatible database version No comment provided by engineer. + + Incompatible version + Incompatible version + No comment provided by engineer. + Incorrect passcode Incorrect passcode @@ -2979,6 +3054,11 @@ This is your link for group %@! Joining group No comment provided by engineer. + + Keep the app open to use it from desktop + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Keep your connections @@ -3039,6 +3119,16 @@ This is your link for group %@! Limitations No comment provided by engineer. + + Linked desktop options + Linked desktop options + No comment provided by engineer. + + + Linked desktops + Linked desktops + No comment provided by engineer. + Live message! Live message! @@ -3608,6 +3698,11 @@ This is your link for group %@! Paste No comment provided by engineer. + + Paste desktop address + Paste desktop address + No comment provided by engineer. + Paste image Paste image @@ -4203,6 +4298,11 @@ This is your link for group %@! Scan QR code No comment provided by engineer. + + Scan QR code from desktop + Scan QR code from desktop + No comment provided by engineer. + Scan code Scan code @@ -4423,6 +4523,11 @@ This is your link for group %@! Servers No comment provided by engineer. + + Session code + Session code + No comment provided by engineer. + Set 1 day Set 1 day @@ -4920,6 +5025,11 @@ It can happen because of some bug or when the connection is compromised.This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. No comment provided by engineer. + + This device name + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. This group has over %lld members, delivery receipts are not sent. @@ -5114,6 +5224,16 @@ To connect, please ask your contact to create another connection link and check To connect, please ask your contact to create another connection link and check that you have a stable network connection. No comment provided by engineer. + + Unlink + Unlink + No comment provided by engineer. + + + Unlink desktop? + Unlink desktop? + No comment provided by engineer. + Unlock Unlock @@ -5204,6 +5324,11 @@ To connect, please ask your contact to create another connection link and check Use for new connections No comment provided by engineer. + + Use from desktop + Use from desktop + No comment provided by engineer. + Use iOS call interface Use iOS call interface @@ -5234,11 +5359,26 @@ To connect, please ask your contact to create another connection link and check Using SimpleX Chat servers. No comment provided by engineer. + + Verify code with desktop + Verify code with desktop + No comment provided by engineer. + + + Verify connection + Verify connection + No comment provided by engineer. + Verify connection security Verify connection security No comment provided by engineer. + + Verify connections + Verify connections + No comment provided by engineer. + Verify security code Verify security code @@ -6426,6 +6566,11 @@ SimpleX servers cannot see your profile. updated group profile rcv group event chat item + + v%@ + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6563,6 +6708,11 @@ SimpleX servers cannot see your profile. SimpleX uses Face ID for local authentication Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX needs microphone access for audio and video calls, and to record voice messages. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 4a76e3ddb4..e913d78d2d 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ Volver No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID ID de mensaje incorrecto @@ -1151,6 +1163,10 @@ Conectar incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Conectando con el servidor… @@ -1198,6 +1222,10 @@ This is your own one-time link! Conectando con el servidor... (error: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Conexión @@ -1218,6 +1246,10 @@ This is your own one-time link! ¡Solicitud de conexión enviada! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Tiempo de conexión expirado @@ -1701,6 +1733,18 @@ This cannot be undone! Descripción No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Desarrollo @@ -1791,11 +1835,19 @@ This cannot be undone! Desconectar server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Descubre y únete a grupos No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. NO uses SimpleX para llamadas de emergencia. @@ -2003,6 +2055,10 @@ This cannot be undone! Introduce el servidor manualmente No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Introduce mensaje de bienvenida… @@ -2773,6 +2829,10 @@ This cannot be undone! Versión de base de datos incompatible No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Código de acceso incorrecto @@ -2938,6 +2998,10 @@ This is your link for group %@! Entrando al grupo No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Conserva tus conexiones @@ -2998,6 +3062,14 @@ This is your link for group %@! Limitaciones No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! ¡Mensaje en vivo! @@ -3565,6 +3637,10 @@ This is your link for group %@! Pegar No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Pegar imagen @@ -4156,6 +4232,10 @@ This is your link for group %@! Escanear código QR No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Escanear código @@ -4376,6 +4456,10 @@ This is your link for group %@! Servidores No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Establecer 1 día @@ -4872,6 +4956,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Esta acción no se puede deshacer. Tu perfil, contactos, mensajes y archivos se perderán irreversiblemente. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Este grupo tiene más de %lld miembros, no se enviarán confirmaciones de entrega. @@ -5062,6 +5150,14 @@ que este enlace ya se haya usado, podría ser un error. Por favor, notifícalo. Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueba que tienes buena conexión de red. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Desbloquear @@ -5152,6 +5248,10 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Usar para conexiones nuevas No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Usar interfaz de llamada de iOS @@ -5182,11 +5282,23 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Usar servidores SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Comprobar la seguridad de la conexión No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Comprobar código de seguridad @@ -6357,6 +6469,10 @@ Los servidores de SimpleX no pueden ver tu perfil. ha actualizado el perfil del grupo rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6494,6 +6610,10 @@ Los servidores de SimpleX no pueden ver tu perfil. SimpleX usa reconocimiento facial para la autenticación local Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX necesita acceso al micrófono para las llamadas de audio, vídeo y para grabar mensajes de voz. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/es.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 2661dc10a1..694b81bcd7 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -854,6 +862,10 @@ Takaisin No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Virheellinen viestin tunniste @@ -1146,6 +1158,10 @@ Yhdistä Incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1183,6 +1199,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Yhteyden muodostaminen palvelimeen… @@ -1193,6 +1217,10 @@ This is your own one-time link! Yhteyden muodostaminen palvelimeen... (virhe: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Yhteys @@ -1213,6 +1241,10 @@ This is your own one-time link! Yhteyspyyntö lähetetty! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Yhteyden aikakatkaisu @@ -1696,6 +1728,18 @@ This cannot be undone! Kuvaus No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Kehitä @@ -1786,11 +1830,19 @@ This cannot be undone! Katkaise server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Löydä ryhmiä ja liity niihin No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Älä käytä SimpleX-sovellusta hätäpuheluihin. @@ -1997,6 +2049,10 @@ This cannot be undone! Syötä palvelin manuaalisesti No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Kirjoita tervetuloviesti… @@ -2765,6 +2821,10 @@ This cannot be undone! Yhteensopimaton tietokantaversio No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Väärä pääsykoodi @@ -2930,6 +2990,10 @@ This is your link for group %@! Liittyy ryhmään No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Pidä kontaktisi @@ -2990,6 +3054,14 @@ This is your link for group %@! Rajoitukset No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Live-viesti! @@ -3555,6 +3627,10 @@ This is your link for group %@! Liitä No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Liitä kuva @@ -4146,6 +4222,10 @@ This is your link for group %@! Skannaa QR-koodi No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Skannaa koodi @@ -4365,6 +4445,10 @@ This is your link for group %@! Palvelimet No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Aseta 1 päivä @@ -4860,6 +4944,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.
Tätä toimintoa ei voi kumota - profiilisi, kontaktisi, viestisi ja tiedostosi poistuvat peruuttamattomasti. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Tässä ryhmässä on yli %lld jäsentä, lähetyskuittauksia ei lähetetä. @@ -5048,6 +5136,14 @@ To connect, please ask your contact to create another connection link and check Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja tarkista, että verkkoyhteytesi on vakaa. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Avaa @@ -5138,6 +5234,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Käytä uusiin yhteyksiin No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Käytä iOS:n puhelujen käyttöliittymää @@ -5168,11 +5268,23 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Käyttää SimpleX Chat -palvelimia. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Tarkista yhteyden suojaus No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Tarkista turvakoodi @@ -6341,6 +6453,10 @@ SimpleX-palvelimet eivät näe profiiliasi. päivitetty ryhmäprofiili rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6478,6 +6594,10 @@ SimpleX-palvelimet eivät näe profiiliasi. SimpleX käyttää Face ID:tä paikalliseen todennukseen Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX tarvitsee mikrofonia ääni- ja videopuheluita ja ääniviestien tallentamista varten. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 6a5f397557..be028fcbb4 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Retour No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Mauvais ID de message @@ -1165,6 +1177,10 @@ Se connecter incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Se connecter à soi-même ? @@ -1209,6 +1225,14 @@ Il s'agit de votre propre lien unique ! Se connecter avec %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Connexion au serveur… @@ -1219,6 +1243,10 @@ Il s'agit de votre propre lien unique ! Connexion au serveur… (erreur : %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Connexion @@ -1239,6 +1267,10 @@ Il s'agit de votre propre lien unique ! Demande de connexion envoyée ! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Délai de connexion @@ -1729,6 +1761,18 @@ Cette opération ne peut être annulée ! Description No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Développer @@ -1819,11 +1863,19 @@ Cette opération ne peut être annulée ! Se déconnecter server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Découvrir et rejoindre des groupes No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. N'utilisez PAS SimpleX pour les appels d'urgence. @@ -2032,6 +2084,10 @@ Cette opération ne peut être annulée ! Entrer un serveur manuellement No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Entrez un message de bienvenue… @@ -2807,6 +2863,10 @@ Cette opération ne peut être annulée ! Version de la base de données incompatible No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Code d'accès erroné @@ -2977,6 +3037,10 @@ Voici votre lien pour le groupe %@ ! Entrain de rejoindre le groupe No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Conserver vos connexions @@ -3037,6 +3101,14 @@ Voici votre lien pour le groupe %@ ! Limitations No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Message dynamique ! @@ -3606,6 +3678,10 @@ Voici votre lien pour le groupe %@ ! Coller No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Coller l'image @@ -4201,6 +4277,10 @@ Voici votre lien pour le groupe %@ ! Scanner un code QR No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Scanner le code @@ -4421,6 +4501,10 @@ Voici votre lien pour le groupe %@ ! Serveurs No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Définir 1 jour @@ -4918,6 +5002,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Ce groupe compte plus de %lld membres, les accusés de réception ne sont pas envoyés. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Pour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d'une connexion réseau stable. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Déverrouiller @@ -5202,6 +5298,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utiliser pour les nouvelles connexions No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Utiliser l'interface d'appel d'iOS @@ -5232,11 +5332,23 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Utilisation des serveurs SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Vérifier la sécurité de la connexion No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Vérifier le code de sécurité @@ -6424,6 +6536,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. mise à jour du profil de groupe rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. SimpleGroup not found!X utilise Face ID pour l'authentification locale Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX a besoin d'un accès au microphone pour les appels audio et vidéo ainsi que pour enregistrer des messages vocaux. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 72cda836ac..9e38f14e6e 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Indietro No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID ID del messaggio errato @@ -1165,6 +1177,10 @@ Connetti in incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Connettersi a te stesso? @@ -1209,6 +1225,14 @@ Questo è il tuo link una tantum! Connettersi con %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Connessione al server… @@ -1219,6 +1243,10 @@ Questo è il tuo link una tantum! Connessione al server… (errore: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Connessione @@ -1239,6 +1267,10 @@ Questo è il tuo link una tantum! Richiesta di connessione inviata! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Connessione scaduta @@ -1729,6 +1761,18 @@ Non è reversibile! Descrizione No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Sviluppa @@ -1819,11 +1863,19 @@ Non è reversibile! Disconnetti server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Scopri ed unisciti ai gruppi No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. NON usare SimpleX per chiamate di emergenza. @@ -2032,6 +2084,10 @@ Non è reversibile! Inserisci il server a mano No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Inserisci il messaggio di benvenuto… @@ -2807,6 +2863,10 @@ Non è reversibile! Versione del database incompatibile No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Codice di accesso errato @@ -2977,6 +3037,10 @@ Questo è il tuo link per il gruppo %@! Ingresso nel gruppo No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Mantieni le tue connessioni @@ -3037,6 +3101,14 @@ Questo è il tuo link per il gruppo %@! Limitazioni No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Messaggio in diretta! @@ -3606,6 +3678,10 @@ Questo è il tuo link per il gruppo %@! Incolla No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Incolla immagine @@ -4201,6 +4277,10 @@ Questo è il tuo link per il gruppo %@! Scansiona codice QR No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Scansiona codice @@ -4421,6 +4501,10 @@ Questo è il tuo link per il gruppo %@! Server No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Imposta 1 giorno @@ -4918,6 +5002,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Questo gruppo ha più di %lld membri, le ricevute di consegna non vengono inviate. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e controlla di avere una connessione di rete stabile. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Sblocca @@ -5202,6 +5298,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Usa per connessioni nuove No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Usa interfaccia di chiamata iOS @@ -5232,11 +5332,23 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Utilizzo dei server SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Verifica la sicurezza della connessione No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Verifica codice di sicurezza @@ -6424,6 +6536,10 @@ I server di SimpleX non possono vedere il tuo profilo. profilo del gruppo aggiornato rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ I server di SimpleX non possono vedere il tuo profilo. SimpleX usa Face ID per l'autenticazione locale Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX ha bisogno dell'accesso al microfono per le chiamate audio e video e per registrare messaggi vocali. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/it.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index b574faecb1..138f67fe2b 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -855,6 +863,10 @@ 戻る No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID メッセージ ID が正しくありません @@ -1148,6 +1160,10 @@ シークレットモードで接続 No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1185,6 +1201,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… サーバーに接続中… @@ -1195,6 +1219,10 @@ This is your own one-time link! サーバーに接続中… (エラー: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection 接続 @@ -1215,6 +1243,10 @@ This is your own one-time link! 接続リクエストを送信しました! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout 接続タイムアウト @@ -1698,6 +1730,18 @@ This cannot be undone! 説明 No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop 開発 @@ -1788,11 +1832,19 @@ This cannot be undone! 切断 server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups グループを見つけて参加する No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. 緊急通報にSimpleXを使用しないでください。 @@ -2000,6 +2052,10 @@ This cannot be undone! サーバを手動で入力 No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… ウェルカムメッセージを入力してください… @@ -2768,6 +2824,10 @@ This cannot be undone! データベースのバージョンと互換性がない No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode パスコードが正しくありません @@ -2933,6 +2993,10 @@ This is your link for group %@! グループに参加 No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections 接続を維持 @@ -2993,6 +3057,14 @@ This is your link for group %@! 制限事項 No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! ライブメッセージ! @@ -3559,6 +3631,10 @@ This is your link for group %@! 貼り付け No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image 画像の貼り付け @@ -4149,6 +4225,10 @@ This is your link for group %@! QRコードを読み込む No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code コードを読み込む @@ -4361,6 +4441,10 @@ This is your link for group %@! サーバ No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day 1日に設定 @@ -4857,6 +4941,10 @@ It can happen because of some bug or when the connection is compromised.あなたのプロフィール、連絡先、メッセージ、ファイルが完全削除されます (※元に戻せません※)。 No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. No comment provided by engineer. @@ -5044,6 +5132,14 @@ To connect, please ask your contact to create another connection link and check 接続するには、連絡先に別の接続リンクを作成するよう依頼し、ネットワーク接続が安定していることを確認してください。 No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock ロック解除 @@ -5134,6 +5230,10 @@ To connect, please ask your contact to create another connection link and check 新しい接続に使う No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface iOS通話インターフェースを使用する @@ -5164,11 +5264,23 @@ To connect, please ask your contact to create another connection link and check SimpleX チャット サーバーを使用する。 No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security 接続のセキュリティを確認 No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code セキュリティコードを確認 @@ -6337,6 +6449,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 グループプロフィールを更新しました rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6474,6 +6590,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 SimpleX はローカル認証に Face ID を使用します Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX では、音声通話やビデオ通話、および音声メッセージの録音のためにマイクへのアクセスが必要です。 diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 92cb85456f..696c7e99fc 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Terug No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Onjuiste bericht-ID @@ -1165,6 +1177,10 @@ Verbind incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Verbinding maken met jezelf? @@ -1209,6 +1225,14 @@ Dit is uw eigen eenmalige link! Verbonden met %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Verbinden met de server… @@ -1219,6 +1243,10 @@ Dit is uw eigen eenmalige link! Verbinden met server... (fout: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Verbinding @@ -1239,6 +1267,10 @@ Dit is uw eigen eenmalige link! Verbindingsverzoek verzonden! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Timeout verbinding @@ -1729,6 +1761,18 @@ Dit kan niet ongedaan gemaakt worden! Beschrijving No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Ontwikkelen @@ -1819,11 +1863,19 @@ Dit kan niet ongedaan gemaakt worden! verbinding verbreken server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Ontdek en sluit je aan bij groepen No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Gebruik SimpleX NIET voor noodoproepen. @@ -2032,6 +2084,10 @@ Dit kan niet ongedaan gemaakt worden! Voer de server handmatig in No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Welkomst bericht invoeren… @@ -2807,6 +2863,10 @@ Dit kan niet ongedaan gemaakt worden! Incompatibele database versie No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Onjuiste toegangscode @@ -2977,6 +3037,10 @@ Dit is jouw link voor groep %@! Deel nemen aan groep No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Behoud uw verbindingen @@ -3037,6 +3101,14 @@ Dit is jouw link voor groep %@! Beperkingen No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Live bericht! @@ -3606,6 +3678,10 @@ Dit is jouw link voor groep %@! Plakken No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Afbeelding plakken @@ -4201,6 +4277,10 @@ Dit is jouw link voor groep %@! Scan QR-code No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Code scannen @@ -4421,6 +4501,10 @@ Dit is jouw link voor groep %@! Servers No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Stel 1 dag in @@ -4918,6 +5002,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Deze actie kan niet ongedaan worden gemaakt. Uw profiel, contacten, berichten en bestanden gaan onomkeerbaar verloren. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Deze groep heeft meer dan %lld -leden, ontvangstbevestigingen worden niet verzonden. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Om verbinding te maken, vraagt u uw contact om een andere verbinding link te maken en te controleren of u een stabiele netwerkverbinding heeft. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Ontgrendelen @@ -5202,6 +5298,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Gebruik voor nieuwe verbindingen No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface De iOS-oproepinterface gebruiken @@ -5232,11 +5332,23 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak SimpleX Chat servers gebruiken. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Controleer de verbindingsbeveiliging No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Controleer de beveiligingscode @@ -6424,6 +6536,10 @@ SimpleX servers kunnen uw profiel niet zien. bijgewerkt groep profiel rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ SimpleX servers kunnen uw profiel niet zien. SimpleX gebruikt Face-ID voor lokale authenticatie Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX heeft microfoon toegang nodig voor audio en video oproepen en om spraak berichten op te nemen. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 5ec6d3ee36..58c3c85b0e 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -297,6 +297,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -869,6 +877,10 @@ Wstecz No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Zły identyfikator wiadomości @@ -1165,6 +1177,10 @@ Połącz incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? Połączyć się ze sobą? @@ -1209,6 +1225,14 @@ To jest twój jednorazowy link! Połącz z %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Łączenie z serwerem… @@ -1219,6 +1243,10 @@ To jest twój jednorazowy link! Łączenie z serwerem... (błąd: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Połączenie @@ -1239,6 +1267,10 @@ To jest twój jednorazowy link! Prośba o połączenie wysłana! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Czas połączenia minął @@ -1729,6 +1761,18 @@ To nie może być cofnięte! Opis No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Deweloperskie @@ -1819,11 +1863,19 @@ To nie może być cofnięte! Rozłącz server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Odkrywaj i dołączaj do grup No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. NIE używaj SimpleX do połączeń alarmowych. @@ -2032,6 +2084,10 @@ To nie może być cofnięte! Wprowadź serwer ręcznie No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Wpisz wiadomość powitalną… @@ -2807,6 +2863,10 @@ To nie może być cofnięte! Niekompatybilna wersja bazy danych No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Nieprawidłowy pin @@ -2977,6 +3037,10 @@ To jest twój link do grupy %@! Dołączanie do grupy No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Zachowaj swoje połączenia @@ -3037,6 +3101,14 @@ To jest twój link do grupy %@! Ograniczenia No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Wiadomość na żywo! @@ -3606,6 +3678,10 @@ To jest twój link do grupy %@! Wklej No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Wklej obraz @@ -4201,6 +4277,10 @@ To jest twój link do grupy %@! Zeskanuj kod QR No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Zeskanuj kod @@ -4421,6 +4501,10 @@ To jest twój link do grupy %@! Serwery No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Ustaw 1 dzień @@ -4918,6 +5002,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Tego działania nie można cofnąć - Twój profil, kontakty, wiadomości i pliki zostaną nieodwracalnie utracone. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. Ta grupa ma ponad %lld członków, potwierdzenia dostawy nie są wysyłane. @@ -5112,6 +5200,14 @@ To connect, please ask your contact to create another connection link and check Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połączenia i sprawdź, czy masz stabilne połączenie z siecią. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Odblokuj @@ -5202,6 +5298,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Użyj dla nowych połączeń No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Użyj interfejsu połączeń iOS @@ -5232,11 +5332,23 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Używanie serwerów SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Weryfikuj bezpieczeństwo połączenia No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Weryfikuj kod bezpieczeństwa @@ -6424,6 +6536,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. zaktualizowano profil grupy rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6561,6 +6677,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. SimpleX używa Face ID do lokalnego uwierzytelniania Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX potrzebuje dostępu do mikrofonu, w celu połączeń audio i wideo oraz nagrywania wiadomości głosowych. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 402fdc9658..d544471c67 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ Назад No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Ошибка ID сообщения @@ -1151,6 +1163,10 @@ Соединиться Инкогнито No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Устанавливается соединение с сервером… @@ -1198,6 +1222,10 @@ This is your own one-time link! Устанавливается соединение с сервером… (ошибка: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Соединение @@ -1218,6 +1246,10 @@ This is your own one-time link! Запрос на соединение отправлен! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Превышено время соединения @@ -1701,6 +1733,18 @@ This cannot be undone! Описание No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Для разработчиков @@ -1791,11 +1835,19 @@ This cannot be undone! Разрыв соединения server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups Найдите и вступите в группы No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. Не используйте SimpleX для экстренных звонков. @@ -2003,6 +2055,10 @@ This cannot be undone! Ввести сервер вручную No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Введите приветственное сообщение… @@ -2771,6 +2827,10 @@ This cannot be undone! Несовместимая версия базы данных No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Неправильный код @@ -2936,6 +2996,10 @@ This is your link for group %@! Вступление в группу No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Сохраните Ваши соединения @@ -2996,6 +3060,14 @@ This is your link for group %@! Ограничения No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Живое сообщение! @@ -3562,6 +3634,10 @@ This is your link for group %@! Вставить No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Вставить изображение @@ -4153,6 +4229,10 @@ This is your link for group %@! Сканировать QR код No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Сканировать код @@ -4372,6 +4452,10 @@ This is your link for group %@! Серверы No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Установить 1 день @@ -4868,6 +4952,10 @@ It can happen because of some bug or when the connection is compromised.Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. В группе более %lld членов, отчёты о доставке выключены. @@ -5057,6 +5145,14 @@ To connect, please ask your contact to create another connection link and check Чтобы установить соединение, попросите Ваш контакт создать еще одну ссылку и проверьте Ваше соединение с сетью. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Разблокировать @@ -5147,6 +5243,10 @@ To connect, please ask your contact to create another connection link and check Использовать для новых соединений No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Использовать интерфейс iOS для звонков @@ -5177,11 +5277,23 @@ To connect, please ask your contact to create another connection link and check Используются серверы, предоставленные SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Проверить безопасность соединения No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Подтвердить код безопасности @@ -6350,6 +6462,10 @@ SimpleX серверы не могут получить доступ к Ваше обновил(а) профиль группы rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6487,6 +6603,10 @@ SimpleX серверы не могут получить доступ к Ваше SimpleX использует Face ID для аутентификации Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX использует микрофон для аудио и видео звонков, и для записи голосовых сообщений. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index e1451378a5..f7e2ad5955 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -284,6 +284,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -846,6 +854,10 @@ กลับ No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID ID ข้อความที่ไม่ดี @@ -1137,6 +1149,10 @@ Connect incognito No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1173,6 +1189,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… กำลังเชื่อมต่อกับเซิร์ฟเวอร์… @@ -1183,6 +1207,10 @@ This is your own one-time link! กำลังเชื่อมต่อกับเซิร์ฟเวอร์... (ข้อผิดพลาด: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection การเชื่อมต่อ @@ -1203,6 +1231,10 @@ This is your own one-time link! ส่งคําขอเชื่อมต่อแล้ว! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout หมดเวลาการเชื่อมต่อ @@ -1684,6 +1716,18 @@ This cannot be undone! คำอธิบาย No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop พัฒนา @@ -1774,10 +1818,18 @@ This cannot be undone! ตัดการเชื่อมต่อ server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. อย่าใช้ SimpleX สําหรับการโทรฉุกเฉิน @@ -1983,6 +2035,10 @@ This cannot be undone! ใส่เซิร์ฟเวอร์ด้วยตนเอง No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… ใส่ข้อความต้อนรับ… @@ -2749,6 +2805,10 @@ This cannot be undone! เวอร์ชันฐานข้อมูลที่เข้ากันไม่ได้ No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode รหัสผ่านไม่ถูกต้อง @@ -2913,6 +2973,10 @@ This is your link for group %@! กำลังจะเข้าร่วมกลุ่ม No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections รักษาการเชื่อมต่อของคุณ @@ -2973,6 +3037,14 @@ This is your link for group %@! ข้อจำกัด No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! ข้อความสด! @@ -3536,6 +3608,10 @@ This is your link for group %@! แปะ No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image แปะภาพ @@ -4124,6 +4200,10 @@ This is your link for group %@! สแกนคิวอาร์โค้ด No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code สแกนรหัส @@ -4341,6 +4421,10 @@ This is your link for group %@! เซิร์ฟเวอร์ No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day ตั้ง 1 วัน @@ -4834,6 +4918,10 @@ It can happen because of some bug or when the connection is compromised.การดำเนินการนี้ไม่สามารถยกเลิกได้ - โปรไฟล์ ผู้ติดต่อ ข้อความ และไฟล์ของคุณจะสูญหายไปอย่างถาวร No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. No comment provided by engineer. @@ -5021,6 +5109,14 @@ To connect, please ask your contact to create another connection link and check ในการเชื่อมต่อ โปรดขอให้ผู้ติดต่อของคุณสร้างลิงก์การเชื่อมต่ออื่น และตรวจสอบว่าคุณมีการเชื่อมต่อเครือข่ายที่เสถียร No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock ปลดล็อค @@ -5110,6 +5206,10 @@ To connect, please ask your contact to create another connection link and check ใช้สำหรับการเชื่อมต่อใหม่ No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface ใช้อินเทอร์เฟซการโทร iOS @@ -5139,11 +5239,23 @@ To connect, please ask your contact to create another connection link and check กำลังใช้เซิร์ฟเวอร์ SimpleX Chat อยู่ No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security ตรวจสอบความปลอดภัยในการเชื่อมต่อ No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code ตรวจสอบรหัสความปลอดภัย @@ -6308,6 +6420,10 @@ SimpleX servers cannot see your profile. อัปเดตโปรไฟล์กลุ่มแล้ว rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6445,6 +6561,10 @@ SimpleX servers cannot see your profile. SimpleX ใช้ Face ID สำหรับการรับรองความถูกต้องในเครื่อง Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX ต้องการการเข้าถึงไมโครโฟนสำหรับการโทรด้วยเสียงและวิดีโอ และเพื่อบันทึกข้อความเสียง diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/th.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index b3b5e9d39a..16b66a27d6 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -289,6 +289,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -853,6 +861,10 @@ Назад No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID Неправильний ідентифікатор повідомлення @@ -1145,6 +1157,10 @@ Підключайтеся інкогніто No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1182,6 +1198,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… Підключення до сервера… @@ -1192,6 +1216,10 @@ This is your own one-time link! Підключення до сервера... (помилка: %@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection Підключення @@ -1212,6 +1240,10 @@ This is your own one-time link! Запит на підключення відправлено! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout Тайм-аут з'єднання @@ -1694,6 +1726,18 @@ This cannot be undone! Опис No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop Розробник @@ -1784,10 +1828,18 @@ This cannot be undone! Від'єднати server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. НЕ використовуйте SimpleX для екстрених викликів. @@ -1993,6 +2045,10 @@ This cannot be undone! Увійдіть на сервер вручну No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… Введіть вітальне повідомлення… @@ -2760,6 +2816,10 @@ This cannot be undone! Несумісна версія бази даних No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode Неправильний пароль @@ -2925,6 +2985,10 @@ This is your link for group %@! Приєднання до групи No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections Зберігайте свої зв'язки @@ -2985,6 +3049,14 @@ This is your link for group %@! Обмеження No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! Живе повідомлення! @@ -3550,6 +3622,10 @@ This is your link for group %@! Вставити No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image Вставити зображення @@ -4141,6 +4217,10 @@ This is your link for group %@! Відскануйте QR-код No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code Сканувати код @@ -4360,6 +4440,10 @@ This is your link for group %@! Сервери No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day Встановити 1 день @@ -4855,6 +4939,10 @@ It can happen because of some bug or when the connection is compromised.Цю дію неможливо скасувати - ваш профіль, контакти, повідомлення та файли будуть безповоротно втрачені. No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. У цій групі більше %lld учасників, підтвердження доставки не надсилаються. @@ -5043,6 +5131,14 @@ To connect, please ask your contact to create another connection link and check Щоб підключитися, попросіть вашого контакта створити інше посилання і перевірте, чи маєте ви стабільне з'єднання з мережею. No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock Розблокувати @@ -5133,6 +5229,10 @@ To connect, please ask your contact to create another connection link and check Використовуйте для нових з'єднань No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface Використовуйте інтерфейс виклику iOS @@ -5163,11 +5263,23 @@ To connect, please ask your contact to create another connection link and check Використання серверів SimpleX Chat. No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security Перевірте безпеку з'єднання No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code Підтвердіть код безпеки @@ -6336,6 +6448,10 @@ SimpleX servers cannot see your profile. оновлений профіль групи rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6473,6 +6589,10 @@ SimpleX servers cannot see your profile. SimpleX використовує Face ID для локальної автентифікації Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX потребує доступу до мікрофона для аудіо та відео дзвінків, а також для запису голосових повідомлень. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 929b54a631..d579e98bb9 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -290,6 +290,14 @@ ( No comment provided by engineer. + + (new) + No comment provided by engineer. + + + (this device v%@) + No comment provided by engineer. + ) ) @@ -858,6 +866,10 @@ 返回 No comment provided by engineer. + + Bad desktop address + No comment provided by engineer. + Bad message ID 错误消息 ID @@ -1151,6 +1163,10 @@ 在隐身状态下连接 No comment provided by engineer. + + Connect to desktop + No comment provided by engineer. + Connect to yourself? No comment provided by engineer. @@ -1188,6 +1204,14 @@ This is your own one-time link! Connect with %@ No comment provided by engineer. + + Connected desktop + No comment provided by engineer. + + + Connected to desktop + No comment provided by engineer. + Connecting to server… 连接服务器中…… @@ -1198,6 +1222,10 @@ This is your own one-time link! 连接服务器中……(错误:%@) No comment provided by engineer. + + Connecting to desktop + No comment provided by engineer. + Connection 连接 @@ -1218,6 +1246,10 @@ This is your own one-time link! 已发送连接请求! No comment provided by engineer. + + Connection terminated + No comment provided by engineer. + Connection timeout 连接超时 @@ -1701,6 +1733,18 @@ This cannot be undone! 描述 No comment provided by engineer. + + Desktop address + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + No comment provided by engineer. + + + Desktop devices + No comment provided by engineer. + Develop 开发 @@ -1791,11 +1835,19 @@ This cannot be undone! 断开连接 server test step + + Disconnect desktop? + No comment provided by engineer. + Discover and join groups 发现和加入群组 No comment provided by engineer. + + Discover on network + No comment provided by engineer. + Do NOT use SimpleX for emergency calls. 请勿使用 SimpleX 进行紧急通话。 @@ -2003,6 +2055,10 @@ This cannot be undone! 手动输入服务器 No comment provided by engineer. + + Enter this device name… + No comment provided by engineer. + Enter welcome message… 输入欢迎消息…… @@ -2773,6 +2829,10 @@ This cannot be undone! 数据库版本不兼容 No comment provided by engineer. + + Incompatible version + No comment provided by engineer. + Incorrect passcode 密码错误 @@ -2938,6 +2998,10 @@ This is your link for group %@! 加入群组中 No comment provided by engineer. + + Keep the app open to use it from desktop + No comment provided by engineer. + Keep your connections 保持连接 @@ -2998,6 +3062,14 @@ This is your link for group %@! 限制 No comment provided by engineer. + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + Live message! 实时消息! @@ -3565,6 +3637,10 @@ This is your link for group %@! 粘贴 No comment provided by engineer. + + Paste desktop address + No comment provided by engineer. + Paste image 粘贴图片 @@ -4156,6 +4232,10 @@ This is your link for group %@! 扫描二维码 No comment provided by engineer. + + Scan QR code from desktop + No comment provided by engineer. + Scan code 扫码 @@ -4376,6 +4456,10 @@ This is your link for group %@! 服务器 No comment provided by engineer. + + Session code + No comment provided by engineer. + Set 1 day 设定1天 @@ -4872,6 +4956,10 @@ It can happen because of some bug or when the connection is compromised.此操作无法撤消——您的个人资料、联系人、消息和文件将不可撤回地丢失。 No comment provided by engineer. + + This device name + No comment provided by engineer. + This group has over %lld members, delivery receipts are not sent. 该组有超过 %lld 个成员,不发送送货单。 @@ -5061,6 +5149,14 @@ To connect, please ask your contact to create another connection link and check 如果要连接,请让您的联系人创建另一个连接链接,并检查您的网络连接是否稳定。 No comment provided by engineer. + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + Unlock 解锁 @@ -5151,6 +5247,10 @@ To connect, please ask your contact to create another connection link and check 用于新连接 No comment provided by engineer. + + Use from desktop + No comment provided by engineer. + Use iOS call interface 使用 iOS 通话界面 @@ -5181,11 +5281,23 @@ To connect, please ask your contact to create another connection link and check 使用 SimpleX Chat 服务器。 No comment provided by engineer. + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + Verify connection security 验证连接安全 No comment provided by engineer. + + Verify connections + No comment provided by engineer. + Verify security code 验证安全码 @@ -6356,6 +6468,10 @@ SimpleX 服务器无法看到您的资料。 已更新的群组资料 rcv group event chat item + + v%@ + No comment provided by engineer. + v%@ (%@) v%@ (%@) @@ -6493,6 +6609,10 @@ SimpleX 服务器无法看到您的资料。 SimpleX 使用Face ID进行本地身份验证 Privacy - Face ID Usage Description + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + SimpleX needs microphone access for audio and video calls, and to record voice messages. SimpleX 需要麦克风访问权限才能进行音频和视频通话,以及录制语音消息。 diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 3af673b19f..d34eb67fc7 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,8 @@ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; /* Privacy - Photo Library Additions Usage Description */ From 624a3abba2d16efa2509c866301faf6a2db392a7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 20 Nov 2023 22:29:18 +0000 Subject: [PATCH 238/294] website: translations (#3412) * Translated using Weblate (German) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ * Translated using Weblate (Arabic) Currently translated at 100.0% (252 of 252 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ --------- Co-authored-by: mlanp Co-authored-by: jonnysemon --- website/langs/ar.json | 6 +++--- website/langs/de.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/langs/ar.json b/website/langs/ar.json index 09a8a268ac..2aa0750b6a 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -20,8 +20,8 @@ "hero-overlay-1-textlink": "لماذا تعتبر معرفات المستخدم ضارة بالخصوصية؟", "hero-overlay-2-textlink": "كيف يعمل SimpleX؟", "hero-overlay-2-title": "لماذا تعتبر معرفات المستخدم ضارة بالخصوصية؟", - "feature-2-title": "تشفير
الصور وملفات بين الطرفين", - "feature-3-title": "المجموعات السرية اللامركزية —
المستخدمون فقط يعرفون بوجودها", + "feature-2-title": "تشفير
الصور والفيديوهات والملفات بين الطرفين", + "feature-3-title": "مجموعات لامركزية مشفرة — المستخدمون فقط يعرفون بوجودها", "feature-5-title": "محادثات سرية اختفائية", "feature-6-title": "تشفير المكالمات الصوتية والفيديو
بين الطرفين", "simplex-network-overlay-1-title": "مقارنة مع بروتوكولات المراسلة P2P", @@ -43,7 +43,7 @@ "feature-1-title": "تشفير الرسائل بين الطرفين مع دعم ماركداون والتحرير", "feature-4-title": "تشفير الرسائل الصوتية بين الطرفين", "privacy-matters-overlay-card-1-p-1": "تستخدم العديد من الشركات الكبيرة معلومات حول من تتصل به لتقدير دخلك، وبيع المنتجات التي لا تحتاجها حقًا، ولتحديد الأسعار.", - "feature-7-title": "قاعدة بيانات محمولة مشفرة — نقل ملف التعريف الخاص بك إلى جهاز آخر", + "feature-7-title": "تخزين التطبيقات المشفرة المحمولة — نقل ملف التعريف إلى جهاز آخر", "feature-8-title": "وضع التخفي —
فريد من نوعه لـ SimpleX Chat", "simplex-private-1-title": "طبقتان من
التشفير بين الطرفين", "simplex-private-2-title": "طبقة إضافية من
تشفير الخادم", diff --git a/website/langs/de.json b/website/langs/de.json index 5eb432720e..cb03588e1f 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -35,14 +35,14 @@ "hero-2-header": "Aufbau einer privaten Verbindung", "hero-overlay-1-title": "Wie funktioniert SimpleX?", "hero-2-header-desc": "Das Video zeigt Ihnen, wie Sie sich mit einem Kontakt mit dessen Einmal-QR-Code persönlich oder per Videotreff verbinden. Sie können sich auch über einen geteilten Einladungslink miteinander verbinden.", - "feature-2-title": "Ende-zu-Ende verschlüsselte
Bilder und Dateien", + "feature-2-title": "Ende-zu-Ende verschlüsselte
Bilder, Videos und Dateien", "feature-4-title": "Ende-zu-Ende verschlüsselte Sprachnachrichten", "feature-1-title": "Ende-zu-Ende verschlüsselte Nachrichten mit Markdowns und Bearbeitungsmöglichkeiten", - "feature-3-title": "Dezentralisierte geheime Gruppen —
Nur die Nutzer wissen, dass diese existieren", + "feature-3-title": "Ende-zu-Ende verschlüsselte dezentralisierte Gruppen — Nur die Nutzer wissen, dass diese überhaupt existieren", "simplex-private-8-title": "Mischen von Nachrichten,
um Korrelationen zu reduzieren", "feature-5-title": "Verschwindende geheime Unterhaltungen", "feature-6-title": "Ende-zu-Ende verschlüsselte Sprach- und Videoanrufe", - "feature-7-title": "Portable und verschlüsselte Datenbank — Verschieben Sie Ihr komplettes Profil einfach auf ein anderes Gerät", + "feature-7-title": "Portable und verschlüsselte App-Datenspeicherung — verschieben Sie das komplette Profil einfach auf ein anderes Gerät", "feature-8-title": "Inkognito-Modus —
Einzigartig in SimpleX Chat", "simplex-network-overlay-1-title": "Vergleich mit P2P Nachrichten-Protokollen", "simplex-private-1-title": "Zwei Schichten der
Ende-zu-Ende Verschlüsselung", From 47cd7de1ae2d6b0eca0df79389af108c3ce82a41 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 00:00:29 +0000 Subject: [PATCH 239/294] core: 5.4.0.4 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- tests/RemoteTests.hs | 12 ++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.yaml b/package.yaml index 386df150aa..da9de37131 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.3 +version: 5.4.0.4 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index abcfcc4c45..d33ffe8b54 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.3 +version: 5.4.0.4 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d9ef5bd648..d8d53aa0c4 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -71,11 +71,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [5, 4, 0, 3] +minRemoteCtrlVersion = AppVersion [5, 4, 0, 4] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [5, 4, 0, 3] +minRemoteHostVersion = AppVersion [5, 4, 0, 4] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index c734c94dbe..b7b34999b0 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -15,7 +15,7 @@ import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M import qualified Network.TLS as TLS import Simplex.Chat.Archive (archiveFilesFolder) -import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..), versionNumber) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File import Simplex.Chat.Remote.Types @@ -120,12 +120,12 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil desktop <## "Remote session invitation:" inv <- getTermLine desktop mobileBob ##> ("/connect remote ctrl " <> inv) - mobileBob <## "connecting new remote controller: My desktop, v5.4.0.3" + mobileBob <## ("connecting new remote controller: My desktop, v" <> versionNumber) mobileBob <## "remote controller stopped" -- the server remains active after rejecting invalid client mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) desktop <## "remote host 1 connecting" desktop <## "Compare session code with host:" sessId <- getTermLine desktop @@ -429,7 +429,7 @@ startRemote mobile desktop = do desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "connecting new remote controller: My desktop, v5.4.0.3" + mobile <## ("connecting new remote controller: My desktop, v" <> versionNumber) desktop <## "new remote host connecting" mobile <## "new remote controller connected" verifyRemoteCtrl mobile desktop @@ -444,7 +444,7 @@ startRemoteStored mobile desktop = do desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) - mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) desktop <## "remote host 1 connecting" mobile <## "remote controller 1 connected" verifyRemoteCtrl mobile desktop @@ -463,7 +463,7 @@ startRemoteDiscover mobile desktop = do mobile <## "1. My desktop" mobile ##> "/confirm remote ctrl 1" - mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) desktop <## "remote host 1 connecting" mobile <## "remote controller 1 connected" verifyRemoteCtrl mobile desktop From da8789ef4c112383817653cb811b134710f4c1c8 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Tue, 21 Nov 2023 14:20:04 +0200 Subject: [PATCH 240/294] desktop: fix RCP tag in AgentErrorType (#3416) --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 33743974cb..85f2203e30 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -4609,7 +4609,7 @@ sealed class AgentErrorType { @Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType() // @Serializable @SerialName("NTF") class NTF(val ntfErr: SMPErrorType): AgentErrorType() @Serializable @SerialName("XFTP") class XFTP(val xftpErr: XFTPErrorType): AgentErrorType() - @Serializable @SerialName("XFTP") class RCP(val rcpErr: RCErrorType): AgentErrorType() + @Serializable @SerialName("RCP") class RCP(val rcpErr: RCErrorType): AgentErrorType() @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() From 5a08a26c9a92d39c5957f04797585ffb25343fb1 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Tue, 21 Nov 2023 16:43:52 +0200 Subject: [PATCH 241/294] desktop: add exception handlers to startReceiver loop (#3417) * desktop: add exception handlers to startReceiver loop * simplify * more exceptions --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../chat/simplex/common/model/SimpleXAPI.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 85f2203e30..9298388df0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -423,8 +423,15 @@ object ChatController { receiverStarted = false break } - val msg = recvMsg(ctrl) - if (msg != null) processReceivedMsg(msg) + try { + val msg = recvMsg(ctrl) + if (msg != null) processReceivedMsg(msg) + } catch (e: Exception) { + Log.e(TAG, "ChatController recvMsg/processReceivedMsg exception: " + e.stackTraceToString()); + } catch (e: Throwable) { + Log.e(TAG, "ChatController recvMsg/processReceivedMsg throwable: " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + } } } } @@ -3558,7 +3565,7 @@ class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = nul fun decodeStr(str: String): APIResponse { return try { json.decodeFromString(str) - } catch(e: Exception) { + } catch(e: Throwable) { try { Log.d(TAG, e.localizedMessage ?: "") val data = json.parseToJsonElement(str).jsonObject @@ -3587,11 +3594,18 @@ class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = nul return APIResponse(CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) } } catch (e: Exception) { - Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString()) + Log.e(TAG, "Exception while parsing chat(s): " + e.stackTraceToString()) + } catch (e: Throwable) { + Log.e(TAG, "Throwable while parsing chat(s): " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) } APIResponse(CR.Response(type, json.encodeToString(data)), remoteHostId, corr) } catch(e: Exception) { APIResponse(CR.Invalid(str), remoteHostId = null) + } catch(e: Throwable) { + Log.e(TAG, "Throwable2 while parsing chat(s): " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + APIResponse(CR.Invalid(str), remoteHostId = null) } } } From a8576c23404679dbd7019cdfffb5b9b3be2eb4ef Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 21 Nov 2023 19:25:50 +0400 Subject: [PATCH 242/294] core: test forwarded message deduplication, mute terminal error (#3414) --- src/Simplex/Chat/View.hs | 11 +++++++---- tests/ChatTests/Groups.hs | 40 +++++++++++++++++++++++++++++++++++++++ tests/ChatTests/Utils.hs | 9 +++++++-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 119d19e620..fc37569fdd 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -360,8 +360,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRAgentConnDeleted acId -> ["completed deleting connection, agent connection id: " <> sShow acId | logLevel <= CLLInfo] CRAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] - CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel e - CRChatError u e -> ttyUser' u $ viewChatError logLevel e + CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel testView e + CRChatError u e -> ttyUser' u $ viewChatError logLevel testView e CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRTimedAction _ _ -> [] where @@ -1737,8 +1737,8 @@ viewRemoteCtrl :: RemoteCtrlInfo -> StyledString viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName} = plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName -viewChatError :: ChatLogLevel -> ChatError -> [StyledString] -viewChatError logLevel = \case +viewChatError :: ChatLogLevel -> Bool -> ChatError -> [StyledString] +viewChatError logLevel testView = \case ChatError err -> case err of CENoActiveUser -> ["error: active user is required"] CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError] @@ -1848,6 +1848,9 @@ viewChatError logLevel = \case SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> viewGroupName g)] SERemoteCtrlNotFound rcId -> ["no remote controller " <> sShow rcId] SERemoteHostNotFound rhId -> ["no remote host " <> sShow rhId] + SEDuplicateGroupMessage {groupId, sharedMsgId} + | testView -> ["duplicate group message, group id: " <> sShow groupId <> ", message id: " <> sShow sharedMsgId] + | otherwise -> [] e -> ["chat db error: " <> sShow e] ChatErrorDatabase err -> case err of DBErrorEncrypted -> ["error: chat database is already encrypted"] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 9278d4071c..03ddf7d57b 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -9,6 +9,7 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (when, void) import qualified Data.ByteString as B +import Data.List (isInfixOf) import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) import Simplex.Chat.Protocol (supportedChatVRange) @@ -107,6 +108,7 @@ chatGroupTests = do it "sends and updates profile when creating contact" testMemberContactProfileUpdate describe "group message forwarding" $ do it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward + it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate it "forward message edit (x.msg.update)" testGroupMsgForwardEdit it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction it "forward message deletion (x.msg.del)" testGroupMsgForwardDeletion @@ -3947,6 +3949,44 @@ setupGroupForwarding3 gName alice bob cath = do void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" +testGroupMsgForwardDeduplicate :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeduplicate = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath + <### [ Predicate ("#team bob> hi there" `isInfixOf`), + StartsWith "duplicate group message, group id: 1" + ] + + threadDelay 1000000 + + -- cath sends x.grp.mem.con on deduplication, so alice doesn't forward anymore + + cath #> "#team hey team" + alice <# "#team cath> hey team" + bob <# "#team cath> hey team" + + alice ##> "/tail #team 2" + alice <# "#team bob> hi there" + alice <# "#team cath> hey team" + + bob ##> "/tail #team 2" + bob <# "#team hi there" + bob <# "#team cath> hey team" + + cath ##> "/tail #team 2" + cath <#. "#team bob> hi there" + cath <# "#team hey team" + testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO () testGroupMsgForwardEdit = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 83b0d507b9..40fe0e6dab 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -282,8 +282,12 @@ cc <##.. ls = do unless prefix $ print ("expected to start from one of: " <> show ls, ", got: " <> l) prefix `shouldBe` True -data ConsoleResponse = ConsoleString String | WithTime String | EndsWith String | StartsWith String - deriving (Show) +data ConsoleResponse + = ConsoleString String + | WithTime String + | EndsWith String + | StartsWith String + | Predicate (String -> Bool) instance IsString ConsoleResponse where fromString = ConsoleString @@ -303,6 +307,7 @@ getInAnyOrder f cc ls = do WithTime s -> dropTime_ l == Just s EndsWith s -> s `isSuffixOf` l StartsWith s -> s `isPrefixOf` l + Predicate p -> p l filterFirst :: (a -> Bool) -> [a] -> [a] filterFirst _ [] = [] filterFirst p (x:xs) From 097242e7a8ffe5dc546bcfabd7fb03763c6bdf8f Mon Sep 17 00:00:00 2001 From: Jesse Horne Date: Tue, 21 Nov 2023 14:37:15 -0500 Subject: [PATCH 243/294] desktop: add image pasting from clipboard (#3378) * first pass at desktop image pasting for multiplatform * removed debug println * fixed bug with pasting text * temp files are deleted now following simplex conventions * optimizations --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../platform/PlatformTextField.desktop.kt | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index c677edc066..74df6b8251 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -22,12 +22,21 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import chat.simplex.common.views.chat.* -import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay +import java.awt.Image +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.UnsupportedFlavorException +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream import java.io.File import java.net.URI +import java.util.* +import javax.imageio.ImageIO +import kotlin.collections.ArrayList import kotlin.io.path.* import kotlin.math.min import kotlin.text.substring @@ -112,12 +121,45 @@ actual fun PlatformTextField( onUpArrow() true } else if (it.key == Key.V && - it.type == KeyEventType.KeyDown && - ((it.isCtrlPressed && !desktopPlatform.isMac()) || (it.isMetaPressed && desktopPlatform.isMac())) && - parseToFiles(clipboard.getText()).isNotEmpty()) { - onFilesPasted(parseToFiles(clipboard.getText())) - true - } + it.type == KeyEventType.KeyDown && + ((it.isCtrlPressed && !desktopPlatform.isMac()) || (it.isMetaPressed && desktopPlatform.isMac()))) { + if (parseToFiles(clipboard.getText()).isNotEmpty()) { + onFilesPasted(parseToFiles(clipboard.getText())) + true + } else { + // It's much faster to getData instead of getting transferable first + val image = try { + Toolkit.getDefaultToolkit().systemClipboard.getData(DataFlavor.imageFlavor) as Image + } catch (e: UnsupportedFlavorException) { + null + } + if (image != null) { + try { + // create BufferedImage from Image + val bi = BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB) + val bgr = bi.createGraphics() + bgr.drawImage(image, 0, 0, null) + bgr.dispose() + // create byte array from BufferedImage + val baos = ByteArrayOutputStream() + ImageIO.write(bi, "png", baos) + val bytes = baos.toByteArray() + withBGApi { + val tempFile = File(tmpDir, "${UUID.randomUUID()}.png") + chatModel.filesToDelete.add(tempFile) + + tempFile.writeBytes(bytes) + composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message) + } + } catch (e: Exception) { + Log.e(TAG, "Pasting image exception: ${e.stackTraceToString()}") + } + true + } else { + false + } + } + } else false }, cursorBrush = SolidColor(MaterialTheme.colors.secondary), From febf3e0a454f6e67524ff10b4aa85796f48d26c7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 21 Nov 2023 23:38:05 +0400 Subject: [PATCH 244/294] ui: v5.4 what's new (#3413) * ios: v5.4 what's new * android * export localizations * update --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../Views/Onboarding/WhatsNewView.swift | 31 ++++++++ .../bg.xcloc/Localized Contents/bg.xliff | 38 +++++++++ .../cs.xcloc/Localized Contents/cs.xliff | 38 +++++++++ .../de.xcloc/Localized Contents/de.xliff | 38 +++++++++ .../en.xcloc/Localized Contents/en.xliff | 49 ++++++++++++ .../es.xcloc/Localized Contents/es.xliff | 38 +++++++++ .../fi.xcloc/Localized Contents/fi.xliff | 38 +++++++++ .../fr.xcloc/Localized Contents/fr.xliff | 38 +++++++++ .../it.xcloc/Localized Contents/it.xliff | 38 +++++++++ .../ja.xcloc/Localized Contents/ja.xliff | 38 +++++++++ .../nl.xcloc/Localized Contents/nl.xliff | 38 +++++++++ .../pl.xcloc/Localized Contents/pl.xliff | 38 +++++++++ .../ru.xcloc/Localized Contents/ru.xliff | 38 +++++++++ .../th.xcloc/Localized Contents/th.xliff | 38 +++++++++ .../uk.xcloc/Localized Contents/uk.xliff | 38 +++++++++ .../Localized Contents/zh-Hans.xliff | 38 +++++++++ .../common/views/onboarding/WhatsNewView.kt | 31 ++++++++ .../commonMain/resources/MR/base/strings.xml | 19 +++++ ...desktop-quantum-resistant-better-groups.md | 78 +++++++++++++++++++ 19 files changed, 740 insertions(+) create mode 100644 blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 966284b0c9..59c2b25b6d 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -283,6 +283,37 @@ private let versionDescriptions: [VersionDescription] = [ ), ] ), + VersionDescription( + version: "v5.4", + post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"), + features: [ + FeatureDescription( + icon: "desktopcomputer", + title: "Link mobile and desktop apps! 🔗", + description: "Via secure quantum resistant protocol." + ), + FeatureDescription( + icon: "person.2", + title: "Better groups", + description: "Faster joining and more reliable messages." + ), + FeatureDescription( + icon: "theatermasks", + title: "Incognito groups", + description: "Create a group using a random profile." + ), + FeatureDescription( + icon: "hand.raised", + title: "Block group members", + description: "To hide unwanted messages." + ), + FeatureDescription( + icon: "gift", + title: "A few more things", + description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" + ), + ] + ), ] private let lastVersion = versionDescriptions.last!.version diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 19e311468a..9db0f95f00 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -386,6 +386,12 @@ - и още! No comment provided by engineer.
+ + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ Лош хеш на съобщението No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages По-добри съобщения @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! Създай SimpleX адрес No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Създайте адрес, за да позволите на хората да се свързват с вас. @@ -2382,6 +2400,10 @@ This cannot be undone! Бързо и без чакане, докато подателят е онлайн! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Любим @@ -2799,6 +2821,10 @@ This cannot be undone! Инкогнито No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Режим инкогнито @@ -3062,6 +3088,10 @@ This is your link for group %@! Ограничения No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4993,6 +5023,10 @@ It can happen because of some bug or when the connection is compromised.За да се свърже, вашият контакт може да сканира QR код или да използва линка в приложението. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection За да направите нова връзка @@ -5308,6 +5342,10 @@ To connect, please ask your contact to create another connection link and check Чрез браузър No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Видео разговор diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 0286c8c684..4d679ab2d5 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -386,6 +386,12 @@ - a více! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ Špatný hash zprávy No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Lepší zprávy @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! Vytvořit SimpleX adresu No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Vytvořit adresu, aby se s vámi lidé mohli spojit. @@ -2382,6 +2400,10 @@ This cannot be undone! Rychle a bez čekání, než bude odesílatel online! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Oblíbené @@ -2799,6 +2821,10 @@ This cannot be undone! Inkognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Režim inkognito @@ -3062,6 +3088,10 @@ This is your link for group %@! Omezení No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4993,6 +5023,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Pro připojení může váš kontakt naskenovat QR kód, nebo použít odkaz v aplikaci. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Vytvoření nového připojení @@ -5308,6 +5342,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Prostřednictvím prohlížeče No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videohovor diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index a69ae55fd3..6c0c0d6807 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -393,6 +393,12 @@ - und mehr! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Ungültiger Nachrichten-Hash No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Verbesserungen bei Nachrichten @@ -901,6 +911,10 @@ Blockieren No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Mitglied blockieren @@ -1351,6 +1365,10 @@ Das ist Ihr eigener Einmal-Link! SimpleX-Adresse erstellen No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Erstellen Sie eine Adresse, damit sich Personen mit Ihnen verbinden können. @@ -2413,6 +2431,10 @@ Das kann nicht rückgängig gemacht werden! Schnell und ohne warten auf den Absender, bis er online ist! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favorit @@ -2833,6 +2855,10 @@ Das kann nicht rückgängig gemacht werden! Inkognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Inkognito-Modus @@ -3101,6 +3127,10 @@ Das ist Ihr Link für die Gruppe %@! Einschränkungen No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Um eine Verbindung herzustellen, kann Ihr Kontakt den QR-Code scannen oder den Link in der App verwenden. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Um eine Verbindung mit einem neuen Kontakt zu erstellen @@ -5359,6 +5393,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Über den Browser No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videoanruf diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index dd302cd1e1..7781cec534 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -392,6 +392,15 @@ - and more! - more stable message delivery. - a bit better groups. +- and more! + No comment provided by engineer. + + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + - optionally notify deleted contacts. +- spaces in profile names. - and more! No comment provided by engineer. @@ -894,6 +903,11 @@ Bad message hash No comment provided by engineer. + + Better groups + Better groups + No comment provided by engineer. + Better messages Better messages @@ -904,6 +918,11 @@ Block No comment provided by engineer. + + Block group members + Block group members + No comment provided by engineer. + Block member Block member @@ -1359,6 +1378,11 @@ This is your own one-time link! Create SimpleX address No comment provided by engineer. + + Create a group using a random profile. + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Create an address to let people connect with you. @@ -2429,6 +2453,11 @@ This cannot be undone! Fast and no wait until the sender is online! No comment provided by engineer. + + Faster joining and more reliable messages. + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favorite @@ -2849,6 +2878,11 @@ This cannot be undone! Incognito No comment provided by engineer. + + Incognito groups + Incognito groups + No comment provided by engineer. + Incognito mode Incognito mode @@ -3119,6 +3153,11 @@ This is your link for group %@! Limitations No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options Linked desktop options @@ -5065,6 +5104,11 @@ It can happen because of some bug or when the connection is compromised.To connect, your contact can scan QR code or use the link in the app. No comment provided by engineer. + + To hide unwanted messages. + To hide unwanted messages. + No comment provided by engineer. + To make a new connection To make a new connection @@ -5389,6 +5433,11 @@ To connect, please ask your contact to create another connection link and check Via browser No comment provided by engineer. + + Via secure quantum resistant protocol. + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Video call diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index e913d78d2d..b22eeb2554 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -386,6 +386,12 @@ - ¡y más! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ Hash de mensaje incorrecto No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Mensajes mejorados @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! Crear tu dirección SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Crea una dirección para que otras personas puedan conectar contigo. @@ -2382,6 +2400,10 @@ This cannot be undone! ¡Rápido y sin necesidad de esperar a que el remitente esté en línea! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favoritos @@ -2799,6 +2821,10 @@ This cannot be undone! Incógnito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Modo incógnito @@ -3062,6 +3088,10 @@ This is your link for group %@! Limitaciones No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4993,6 +5023,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Para conectarse, tu contacto puede escanear el código QR o usar el enlace en la aplicación. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Para hacer una conexión nueva @@ -5309,6 +5343,10 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Mediante navegador No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videollamada diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 694b81bcd7..6999871574 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -383,6 +383,12 @@ - ja paljon muuta! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -876,6 +882,10 @@ Virheellinen viestin tarkiste No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Parempia viestejä @@ -885,6 +895,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1324,6 +1338,10 @@ This is your own one-time link! Luo SimpleX-osoite No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Luo osoite, jolla ihmiset voivat ottaa sinuun yhteyttä. @@ -2374,6 +2392,10 @@ This cannot be undone! Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Suosikki @@ -2791,6 +2813,10 @@ This cannot be undone! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Incognito-tila @@ -3054,6 +3080,10 @@ This is your link for group %@! Rajoitukset No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4981,6 +5011,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Kontaktisi voi muodostaa yhteyden skannaamalla QR-koodin tai käyttämällä sovelluksessa olevaa linkkiä. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Uuden yhteyden luominen @@ -5295,6 +5329,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Selaimella No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videopuhelu diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index be028fcbb4..a0c5b6e991 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -393,6 +393,12 @@ - et bien d'autres choses encore ! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Mauvais hash de message No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Meilleurs messages @@ -901,6 +911,10 @@ Bloquer No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Bloquer ce membre @@ -1351,6 +1365,10 @@ Il s'agit de votre propre lien unique ! Créer une adresse SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Créez une adresse pour permettre aux gens de vous contacter. @@ -2413,6 +2431,10 @@ Cette opération ne peut être annulée ! Rapide et ne nécessitant pas d'attendre que l'expéditeur soit en ligne ! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favoris @@ -2833,6 +2855,10 @@ Cette opération ne peut être annulée ! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Mode Incognito @@ -3101,6 +3127,10 @@ Voici votre lien pour le groupe %@ ! Limitations No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Pour se connecter, votre contact peut scanner le code QR ou utiliser le lien dans l'application. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Pour établir une nouvelle connexion @@ -5359,6 +5393,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Via navigateur No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Appel vidéo diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 9e38f14e6e..ec62aed15a 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -393,6 +393,12 @@ - e altro ancora! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Hash del messaggio errato No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Messaggi migliorati @@ -901,6 +911,10 @@ Blocca No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Blocca membro @@ -1351,6 +1365,10 @@ Questo è il tuo link una tantum! Crea indirizzo SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Crea un indirizzo per consentire alle persone di connettersi con te. @@ -2413,6 +2431,10 @@ Non è reversibile! Veloce e senza aspettare che il mittente sia in linea! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Preferito @@ -2833,6 +2855,10 @@ Non è reversibile! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Modalità incognito @@ -3101,6 +3127,10 @@ Questo è il tuo link per il gruppo %@! Limitazioni No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Per connettervi, il tuo contatto può scansionare il codice QR o usare il link nell'app. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Per creare una nuova connessione @@ -5359,6 +5393,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Via browser No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Videochiamata diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 138f67fe2b..37b942f7d1 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -383,6 +383,12 @@ - などなど! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -877,6 +883,10 @@ メッセージのハッシュ値問題 No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages より良いメッセージ @@ -886,6 +896,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1326,6 +1340,10 @@ This is your own one-time link! SimpleXアドレスの作成 No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. 人とつながるためのアドレスを作成する。 @@ -2377,6 +2395,10 @@ This cannot be undone! 送信者がオンラインになるまでの待ち時間がなく、速い! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite お気に入り @@ -2794,6 +2816,10 @@ This cannot be undone! シークレットモード No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode シークレットモード @@ -3057,6 +3083,10 @@ This is your link for group %@! 制限事項 No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4977,6 +5007,10 @@ It can happen because of some bug or when the connection is compromised.接続するにはQRコードを読み込むか、アプリ内のリンクを使用する必要があります。 No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection 新規に接続する場合 @@ -5291,6 +5325,10 @@ To connect, please ask your contact to create another connection link and check ブラウザ経由 No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call ビデオ通話 diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 696c7e99fc..5119dc9a3f 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -393,6 +393,12 @@ - en meer! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Onjuiste bericht hash No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Betere berichten @@ -901,6 +911,10 @@ Blokkeren No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Lid blokkeren @@ -1351,6 +1365,10 @@ Dit is uw eigen eenmalige link! Maak een SimpleX adres aan No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Maak een adres aan zodat mensen contact met je kunnen opnemen. @@ -2413,6 +2431,10 @@ Dit kan niet ongedaan gemaakt worden! Snel en niet wachten tot de afzender online is! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Favoriet @@ -2833,6 +2855,10 @@ Dit kan niet ongedaan gemaakt worden! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Incognito modus @@ -3101,6 +3127,10 @@ Dit is jouw link voor groep %@! Beperkingen No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Om verbinding te maken, kan uw contact de QR-code scannen of de link in de app gebruiken. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Om een nieuwe verbinding te maken @@ -5359,6 +5393,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Via browser No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call video oproep diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 58c3c85b0e..518addb4ca 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -393,6 +393,12 @@ - i więcej! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -891,6 +897,10 @@ Zły hash wiadomości No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Lepsze wiadomości @@ -901,6 +911,10 @@ Zablokuj No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member Zablokuj członka @@ -1351,6 +1365,10 @@ To jest twój jednorazowy link! Utwórz adres SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Utwórz adres, aby ludzie mogli się z Tobą połączyć. @@ -2413,6 +2431,10 @@ To nie może być cofnięte! Szybko i bez czekania aż nadawca będzie online! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Ulubione @@ -2833,6 +2855,10 @@ To nie może być cofnięte! Incognito No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Tryb incognito @@ -3101,6 +3127,10 @@ To jest twój link do grupy %@! Ograniczenia No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -5041,6 +5071,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Aby się połączyć, Twój kontakt może zeskanować kod QR lub skorzystać z linku w aplikacji. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Aby nawiązać nowe połączenie @@ -5359,6 +5393,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Przez przeglądarkę No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Połączenie wideo diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index d544471c67..7744fa908a 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -386,6 +386,12 @@ - и прочее! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ Ошибка хэш сообщения No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Улучшенные сообщения @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! Создать адрес SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Создайте адрес, чтобы можно было соединиться с вами. @@ -2380,6 +2398,10 @@ This cannot be undone! Быстрые и не нужно ждать, когда отправитель онлайн! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Избранный @@ -2797,6 +2819,10 @@ This cannot be undone! Инкогнито No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Режим Инкогнито @@ -3060,6 +3086,10 @@ This is your link for group %@! Ограничения No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4989,6 +5019,10 @@ It can happen because of some bug or when the connection is compromised.Чтобы соединиться с Вами, Ваш контакт может отсканировать QR-код или использовать ссылку в приложении. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Чтобы соединиться @@ -5304,6 +5338,10 @@ To connect, please ask your contact to create another connection link and check В браузере No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Видеозвонок diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index f7e2ad5955..3c37295754 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -377,6 +377,12 @@ - และอื่น ๆ! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -868,6 +874,10 @@ แฮชข้อความไม่ดี No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages ข้อความที่ดีขึ้น @@ -877,6 +887,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1314,6 +1328,10 @@ This is your own one-time link! สร้างที่อยู่ SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. สร้างที่อยู่เพื่อให้ผู้อื่นเชื่อมต่อกับคุณ @@ -2359,6 +2377,10 @@ This cannot be undone! รวดเร็วและไม่ต้องรอจนกว่าผู้ส่งจะออนไลน์! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite ที่ชอบ @@ -2776,6 +2798,10 @@ This cannot be undone! ไม่ระบุตัวตน No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode โหมดไม่ระบุตัวตน @@ -3037,6 +3063,10 @@ This is your link for group %@! ข้อจำกัด No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4954,6 +4984,10 @@ It can happen because of some bug or when the connection is compromised.เพื่อการเชื่อมต่อ ผู้ติดต่อของคุณสามารถสแกนคิวอาร์โค้ดหรือใช้ลิงก์ในแอป No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection เพื่อสร้างการเชื่อมต่อใหม่ @@ -5266,6 +5300,10 @@ To connect, please ask your contact to create another connection link and check ผ่านเบราว์เซอร์ No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call การสนทนาทางวิดีโอ diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 16b66a27d6..d6f60cb702 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -382,6 +382,12 @@ - і багато іншого! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -875,6 +881,10 @@ Поганий хеш повідомлення No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages Кращі повідомлення @@ -884,6 +894,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1323,6 +1337,10 @@ This is your own one-time link! Створіть адресу SimpleX No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. Створіть адресу, щоб люди могли з вами зв'язатися. @@ -2369,6 +2387,10 @@ This cannot be undone! Швидко і без очікування, поки відправник буде онлайн! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite Улюблений @@ -2786,6 +2808,10 @@ This cannot be undone! Інкогніто No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode Режим інкогніто @@ -3049,6 +3075,10 @@ This is your link for group %@! Обмеження No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4976,6 +5006,10 @@ It can happen because of some bug or when the connection is compromised.Щоб підключитися, ваш контакт може відсканувати QR-код або скористатися посиланням у додатку. No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection Щоб створити нове з'єднання @@ -5290,6 +5324,10 @@ To connect, please ask your contact to create another connection link and check Через браузер No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call Відеодзвінок diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index d579e98bb9..1e9bd9ac1e 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -386,6 +386,12 @@ - 以及更多! No comment provided by engineer. + + - optionally notify deleted contacts. +- spaces in profile names. +- and more! + No comment provided by engineer. + - voice messages up to 5 minutes. - custom time to disappear. @@ -880,6 +886,10 @@ 错误消息散列 No comment provided by engineer. + + Better groups + No comment provided by engineer. + Better messages 更好的消息 @@ -889,6 +899,10 @@ Block No comment provided by engineer. + + Block group members + No comment provided by engineer. + Block member No comment provided by engineer. @@ -1329,6 +1343,10 @@ This is your own one-time link! 创建 SimpleX 地址 No comment provided by engineer. + + Create a group using a random profile. + No comment provided by engineer. + Create an address to let people connect with you. 创建一个地址,让人们与您联系。 @@ -2382,6 +2400,10 @@ This cannot be undone! 快速且无需等待发件人在线! No comment provided by engineer. + + Faster joining and more reliable messages. + No comment provided by engineer. + Favorite 最喜欢 @@ -2799,6 +2821,10 @@ This cannot be undone! 隐身聊天 No comment provided by engineer. + + Incognito groups + No comment provided by engineer. + Incognito mode 隐身模式 @@ -3062,6 +3088,10 @@ This is your link for group %@! 限制 No comment provided by engineer. + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + Linked desktop options No comment provided by engineer. @@ -4993,6 +5023,10 @@ It can happen because of some bug or when the connection is compromised.您的联系人可以扫描二维码或使用应用程序中的链接来建立连接。 No comment provided by engineer. + + To hide unwanted messages. + No comment provided by engineer. + To make a new connection 建立新连接 @@ -5308,6 +5342,10 @@ To connect, please ask your contact to create another connection link and check 通过浏览器 No comment provided by engineer. + + Via secure quantum resistant protocol. + No comment provided by engineer. + Video call 视频通话 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 390fd0f14f..e1a81d925c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -431,6 +431,37 @@ private val versionDescriptions: List = listOf( ) ) ), + VersionDescription( + version = "v5.4", + post = "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html", + features = listOf( + FeatureDescription( + icon = MR.images.ic_desktop, + titleId = MR.strings.v5_4_link_mobile_desktop, + descrId = MR.strings.v5_4_link_mobile_desktop_descr + ), + FeatureDescription( + icon = MR.images.ic_group, + titleId = MR.strings.v5_4_better_groups, + descrId = MR.strings.v5_4_better_groups_descr + ), + FeatureDescription( + icon = MR.images.ic_theater_comedy, + titleId = MR.strings.v5_4_incognito_groups, + descrId = MR.strings.v5_4_incognito_groups_descr + ), + FeatureDescription( + icon = MR.images.ic_back_hand, + titleId = MR.strings.v5_4_block_group_members, + descrId = MR.strings.v5_4_block_group_members_descr + ), + FeatureDescription( + icon = MR.images.ic_redeem, + titleId = MR.strings.v5_2_more_things, + descrId = MR.strings.v5_4_more_things_descr + ) + ) + ), ) private val lastVersion = versionDescriptions.last().version diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 616e78651d..177dcb55f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1608,6 +1608,25 @@ Toggle incognito when connecting. 6 new interface languages Arabic, Bulgarian, Finnish, Hebrew, Thai and Ukrainian - thanks to the users and Weblate. + Connect desktop and mobile! + Use your mobile app chat profile via desktop app. + Group improvements + Create groups incognito, block group members, faster join via link, and more. + Notify about contact deletion + You can optionally notify contacts when deleting them. + Checking SimpleX links + Detection of previously used and your own SimpleX links. + Spaces in profile names + You can now add spaces to your profile name. + Link mobile and desktop apps! 🔗 + Via secure quantum resistant protocol. + Better groups + Faster joining and more reliable messages. + Incognito groups + Create a group using a random profile. + Block group members + To hide unwanted messages. + - optionally notify deleted contacts.\n- profile names with spaces.\n- and more! seconds diff --git a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md new file mode 100644 index 0000000000..aef4dddf64 --- /dev/null +++ b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md @@ -0,0 +1,78 @@ +--- +layout: layouts/article.html +title: "SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups." +date: 2023-11-25 +preview: SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups. +# image: images/20231125-remote-desktop.jpg +draft: true +imageWide: true +permalink: "/blog/20231125-simplex-chat-v5-4-quantum-resistant-mobile-from-desktop-better-groups.html" +--- + +TODO stub for release announcement + +# SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups. + +**Published:** Nov 25, 2023 + +- [Quick start: control SimpleX Chat mobile app from CLI](#⚡️-quick-start-use-profiles-in-SimpleX-Chat-mobile-from-desktop-app) +- [What's the problem](#whats-the-problem) +- [Why didn't we use some existing solution?](#why-didnt-we-use-some-existing-solution) + +## ⚡️ Quick start: use profiles in SimpleX Chat mobile from desktop app + +## What's the problem? + +Currently you cannot use the same SimpleX Chat profile on mobile and desktop devices. Even though you can use small groups instead of direct conversations as a workaround, it is quite inconvenient – read status and delivery receipts become much less useful. + +So, we need a way to use the same profile on desktop as we use on mobile. + +If SimpleX Chat profile was stored on the server, the problem would have been simpler - you can just connect to it from another device. But even in this case, accessing the conversation history without compromising the security of double ratchet end-to-end encryption is not really possible. + +So we decided to implement the solution that is similar to what WhatsApp and WeChat did in early days - allowing a desktop device access profile on mobile via network. Unlike these big apps, we don't use the server to connect to mobile, but instead use the connection over the local network. + +The downside of this approach is that mobile device has to be with you and connected to the same local network (and in case of iOS, the app has to be in the foreground as well). But the upside is that we the connection can be secure, and that you do not have to have a copy of your profiles on the desktop, which usually has lower security. + +## Why didn't we use some existing solution? + +While there are several existing protocols for remote access, all of them are vulnerable to spoofing and man + + in many cases support of sending files and images is not very good, and sending videos and large files is simply impossible. There are currently these problems: + +- the sender has to be online for file transfer to complete, once it was confirmed by the recipient. +- when the file is sent to the group, the sender will have to transfer it separately to each member, creating a lot of traffic. +- the file transfer is slow, as it is sent in small chunks - approximately 16kb per message. + +As a result, we limited the supported size of files in the app to 8mb. Even for supported files, it is quite inefficient for sending any files to large groups. + +## SimpleX platform + +Some links to answer the most common questions: + +[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers). + +[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users). + +[Technical details and limitations](https://github.com/simplex-chat/simplex-chat#privacy-technical-details-and-limitations). + +[How SimpleX is different from Session, Matrix, Signal, etc.](https://github.com/simplex-chat/simplex-chat/blob/stable/README.md#frequently-asked-questions). + +Please also see our [website](https://simplex.chat). + +## Help us with donations + +Huge thank you to everybody who donated to SimpleX Chat! + +We are prioritizing users privacy and security - it would be impossible without your support. + +Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure. + +Your donations help us raise more funds – any amount, even the price of the cup of coffee, makes a big difference for us. + +See [this section](https://github.com/simplex-chat/simplex-chat/tree/master#help-us-with-donations) for the ways to donate. + +Thank you, + +Evgeny + +SimpleX Chat founder From d0f43628ef2b32e828eda69f22d25d35c39647e3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 20:30:21 +0000 Subject: [PATCH 245/294] ui: translations (#3421) * Translated using Weblate (Italian) Currently translated at 100.0% (1483 of 1483 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1332 of 1332 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1483 of 1483 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 97.2% (1442 of 1483 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1483 of 1483 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1332 of 1332 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * android: fix formatted strings * ios: imp/exp localizations --------- Co-authored-by: Random Co-authored-by: Eric Co-authored-by: No name Co-authored-by: M1K4 --- .../bg.xcloc/Localized Contents/bg.xliff | 4 +- .../cs.xcloc/Localized Contents/cs.xliff | 4 +- .../de.xcloc/Localized Contents/de.xliff | 4 +- .../en.xcloc/Localized Contents/en.xliff | 6 +- .../es.xcloc/Localized Contents/es.xliff | 4 +- .../fi.xcloc/Localized Contents/fi.xliff | 4 +- .../fr.xcloc/Localized Contents/fr.xliff | 4 +- .../it.xcloc/Localized Contents/it.xliff | 36 ++++++- .../ja.xcloc/Localized Contents/ja.xliff | 4 +- .../nl.xcloc/Localized Contents/nl.xliff | 36 ++++++- .../pl.xcloc/Localized Contents/pl.xliff | 4 +- .../ru.xcloc/Localized Contents/ru.xliff | 4 +- .../th.xcloc/Localized Contents/th.xliff | 4 +- .../uk.xcloc/Localized Contents/uk.xliff | 4 +- .../Localized Contents/zh-Hans.xliff | 4 +- apps/ios/it.lproj/Localizable.strings | 93 +++++++++++++++++++ .../it.lproj/SimpleX--iOS--InfoPlist.strings | 3 + apps/ios/nl.lproj/Localizable.strings | 93 +++++++++++++++++++ .../nl.lproj/SimpleX--iOS--InfoPlist.strings | 3 + .../commonMain/resources/MR/es/strings.xml | 73 +++++++++++++-- .../commonMain/resources/MR/it/strings.xml | 44 +++++++++ .../commonMain/resources/MR/nl/strings.xml | 44 +++++++++ .../resources/MR/zh-rCN/strings.xml | 44 +++++++++ 23 files changed, 485 insertions(+), 38 deletions(-) diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 9db0f95f00..b5ae218ab9 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -386,9 +386,9 @@ - и още! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 4d679ab2d5..1e7b2c1fba 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -386,9 +386,9 @@ - a více! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 6c0c0d6807..4d37da1f3a 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -393,9 +393,9 @@ - und mehr! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 7781cec534..6d70bd1221 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -395,12 +395,12 @@ - and more! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index b22eeb2554..cc93c0531d 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -386,9 +386,9 @@ - ¡y más! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 6999871574..c1c46aa42a 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -383,9 +383,9 @@ - ja paljon muuta! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index a0c5b6e991..78a23315cb 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -393,9 +393,9 @@ - et bien d'autres choses encore ! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index ec62aed15a..6f99b16b77 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -299,10 +299,12 @@ (new) + (nuovo) No comment provided by engineer. (this device v%@) + (questo dispositivo v%@) No comment provided by engineer. @@ -393,9 +395,9 @@ - e altro ancora! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. @@ -885,6 +887,7 @@ Bad desktop address + Indirizzo desktop errato No comment provided by engineer. @@ -1193,6 +1196,7 @@ Connect to desktop + Connetti al desktop No comment provided by engineer. @@ -1241,10 +1245,12 @@ Questo è il tuo link una tantum! Connected desktop + Desktop connesso No comment provided by engineer. Connected to desktop + Connesso al desktop No comment provided by engineer. @@ -1259,6 +1265,7 @@ Questo è il tuo link una tantum! Connecting to desktop + Connessione al desktop No comment provided by engineer. @@ -1283,6 +1290,7 @@ Questo è il tuo link una tantum! Connection terminated + Connessione terminata No comment provided by engineer. @@ -1781,14 +1789,17 @@ Non è reversibile! Desktop address + Indirizzo desktop No comment provided by engineer. Desktop app version %@ is not compatible with this app. + La versione dell'app desktop %@ non è compatibile con questa app. No comment provided by engineer. Desktop devices + Dispositivi desktop No comment provided by engineer. @@ -1883,6 +1894,7 @@ Non è reversibile! Disconnect desktop? + Disconnettere il desktop? No comment provided by engineer. @@ -1892,6 +1904,7 @@ Non è reversibile! Discover on network + Trova nella rete No comment provided by engineer. @@ -2066,10 +2079,12 @@ Non è reversibile! Encryption re-negotiation error + Errore di rinegoziazione crittografia message decrypt error item Encryption re-negotiation failed. + Rinegoziazione crittografia fallita. No comment provided by engineer. @@ -2104,6 +2119,7 @@ Non è reversibile! Enter this device name… + Inserisci il nome di questo dispositivo… No comment provided by engineer. @@ -2891,6 +2907,7 @@ Non è reversibile! Incompatible version + Versione incompatibile No comment provided by engineer. @@ -3065,6 +3082,7 @@ Questo è il tuo link per il gruppo %@! Keep the app open to use it from desktop + Tieni aperta l'app per usarla dal desktop No comment provided by engineer. @@ -3133,10 +3151,12 @@ Questo è il tuo link per il gruppo %@! Linked desktop options + Opzioni del desktop collegato No comment provided by engineer. Linked desktops + Desktop collegati No comment provided by engineer. @@ -3710,6 +3730,7 @@ Questo è il tuo link per il gruppo %@! Paste desktop address + Incolla l'indirizzo desktop No comment provided by engineer. @@ -4309,6 +4330,7 @@ Questo è il tuo link per il gruppo %@! Scan QR code from desktop + Scansiona codice QR dal desktop No comment provided by engineer. @@ -4533,6 +4555,7 @@ Questo è il tuo link per il gruppo %@! Session code + Codice di sessione No comment provided by engineer. @@ -5034,6 +5057,7 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. This device name + Il nome di questo dispositivo No comment provided by engineer. @@ -5236,10 +5260,12 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Unlink + Scollega No comment provided by engineer. Unlink desktop? + Scollegare il desktop? No comment provided by engineer. @@ -5334,6 +5360,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Use from desktop + Usa dal desktop No comment provided by engineer. @@ -5368,10 +5395,12 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Verify code with desktop + Verifica il codice con il desktop No comment provided by engineer. Verify connection + Verifica la connessione No comment provided by engineer. @@ -5381,6 +5410,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Verify connections + Verifica le connessioni No comment provided by engineer. @@ -6576,6 +6606,7 @@ I server di SimpleX non possono vedere il tuo profilo. v%@ + v%@ No comment provided by engineer. @@ -6717,6 +6748,7 @@ I server di SimpleX non possono vedere il tuo profilo. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX usa l'accesso alla rete locale per consentire di usare il profilo di chat tramite l'app desktop sulla stessa rete. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 37b942f7d1..bae6d393d0 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -383,9 +383,9 @@ - などなど! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 5119dc9a3f..8b6633c3db 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -299,10 +299,12 @@ (new) + (nieuw) No comment provided by engineer. (this device v%@) + (dit apparaat v%@) No comment provided by engineer. @@ -393,9 +395,9 @@ - en meer! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. @@ -885,6 +887,7 @@ Bad desktop address + Onjuist desktopadres No comment provided by engineer. @@ -1193,6 +1196,7 @@ Connect to desktop + Verbinden met desktop No comment provided by engineer. @@ -1241,10 +1245,12 @@ Dit is uw eigen eenmalige link! Connected desktop + Verbonden desktop No comment provided by engineer. Connected to desktop + Verbonden met desktop No comment provided by engineer. @@ -1259,6 +1265,7 @@ Dit is uw eigen eenmalige link! Connecting to desktop + Verbinding maken met desktop No comment provided by engineer. @@ -1283,6 +1290,7 @@ Dit is uw eigen eenmalige link! Connection terminated + Verbinding beëindigd No comment provided by engineer. @@ -1781,14 +1789,17 @@ Dit kan niet ongedaan gemaakt worden! Desktop address + Desktop adres No comment provided by engineer. Desktop app version %@ is not compatible with this app. + Desktop-app-versie %@ is niet compatibel met deze app. No comment provided by engineer. Desktop devices + Desktop apparaten No comment provided by engineer. @@ -1883,6 +1894,7 @@ Dit kan niet ongedaan gemaakt worden! Disconnect desktop? + Desktop loskoppelen? No comment provided by engineer. @@ -1892,6 +1904,7 @@ Dit kan niet ongedaan gemaakt worden! Discover on network + Ontdek via netwerk No comment provided by engineer. @@ -2066,10 +2079,12 @@ Dit kan niet ongedaan gemaakt worden! Encryption re-negotiation error + Fout bij heronderhandeling van codering message decrypt error item Encryption re-negotiation failed. + Opnieuw onderhandelen over de codering is mislukt. No comment provided by engineer. @@ -2104,6 +2119,7 @@ Dit kan niet ongedaan gemaakt worden! Enter this device name… + Voer deze apparaatnaam in… No comment provided by engineer. @@ -2891,6 +2907,7 @@ Dit kan niet ongedaan gemaakt worden! Incompatible version + Incompatibele versie No comment provided by engineer. @@ -3065,6 +3082,7 @@ Dit is jouw link voor groep %@! Keep the app open to use it from desktop + Houd de app geopend om deze vanaf de desktop te gebruiken No comment provided by engineer. @@ -3133,10 +3151,12 @@ Dit is jouw link voor groep %@! Linked desktop options + Gekoppelde desktop opties No comment provided by engineer. Linked desktops + Gelinkte desktops No comment provided by engineer. @@ -3710,6 +3730,7 @@ Dit is jouw link voor groep %@! Paste desktop address + Desktopadres plakken No comment provided by engineer. @@ -4309,6 +4330,7 @@ Dit is jouw link voor groep %@! Scan QR code from desktop + Scan QR-code vanaf uw desktop No comment provided by engineer. @@ -4533,6 +4555,7 @@ Dit is jouw link voor groep %@! Session code + Sessie code No comment provided by engineer. @@ -5034,6 +5057,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. This device name + Deze apparaatnaam No comment provided by engineer. @@ -5236,10 +5260,12 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Unlink + Ontkoppelen No comment provided by engineer. Unlink desktop? + Desktop ontkoppelen? No comment provided by engineer. @@ -5334,6 +5360,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Use from desktop + Gebruik vanaf desktop No comment provided by engineer. @@ -5368,10 +5395,12 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Verify code with desktop + Code verifiëren met desktop No comment provided by engineer. Verify connection + Controleer de verbinding No comment provided by engineer. @@ -5381,6 +5410,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Verify connections + Controleer verbindingen No comment provided by engineer. @@ -6576,6 +6606,7 @@ SimpleX servers kunnen uw profiel niet zien. v%@ + v%@ No comment provided by engineer. @@ -6717,6 +6748,7 @@ SimpleX servers kunnen uw profiel niet zien. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX maakt gebruik van lokale netwerktoegang om het gebruik van een gebruikerschatprofiel via de desktop-app op hetzelfde netwerk mogelijk te maken. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 518addb4ca..22d10aa434 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -393,9 +393,9 @@ - i więcej! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 7744fa908a..7fd4cd51b3 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -386,9 +386,9 @@ - и прочее! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 3c37295754..6ada6dbbbe 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -377,9 +377,9 @@ - และอื่น ๆ! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index d6f60cb702..868f420630 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -382,9 +382,9 @@ - і багато іншого! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 1e9bd9ac1e..77374aefa5 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -386,9 +386,9 @@ - 以及更多! No comment provided by engineer. - + - optionally notify deleted contacts. -- spaces in profile names. +- profile names with spaces. - and more! No comment provided by engineer. diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 256a64a663..eb77c2fe1e 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -43,6 +43,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(nuovo)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(questo dispositivo v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -548,6 +554,9 @@ /* No comment provided by engineer. */ "Back" = "Indietro"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Indirizzo desktop errato"; + /* integrity error chat item */ "bad message hash" = "hash del messaggio errato"; @@ -771,6 +780,9 @@ /* No comment provided by engineer. */ "Connect incognito" = "Connetti in incognito"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Connetti al desktop"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "connettiti agli sviluppatori di SimpleX Chat."; @@ -801,9 +813,15 @@ /* No comment provided by engineer. */ "connected" = "connesso/a"; +/* No comment provided by engineer. */ +"Connected desktop" = "Desktop connesso"; + /* rcv group event chat item */ "connected directly" = "si è connesso/a direttamente"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Connesso al desktop"; + /* No comment provided by engineer. */ "connecting" = "in connessione"; @@ -828,6 +846,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connessione al server… (errore: %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Connessione al desktop"; + /* chat list item title */ "connecting…" = "in connessione…"; @@ -846,6 +867,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Richiesta di connessione inviata!"; +/* No comment provided by engineer. */ +"Connection terminated" = "Connessione terminata"; + /* No comment provided by engineer. */ "Connection timeout" = "Connessione scaduta"; @@ -1173,6 +1197,15 @@ /* No comment provided by engineer. */ "Description" = "Descrizione"; +/* No comment provided by engineer. */ +"Desktop address" = "Indirizzo desktop"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "La versione dell'app desktop %@ non è compatibile con questa app."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Dispositivi desktop"; + /* No comment provided by engineer. */ "Develop" = "Sviluppa"; @@ -1236,9 +1269,15 @@ /* server test step */ "Disconnect" = "Disconnetti"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Disconnettere il desktop?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Scopri ed unisciti ai gruppi"; +/* No comment provided by engineer. */ +"Discover on network" = "Trova nella rete"; + /* No comment provided by engineer. */ "Do it later" = "Fallo dopo"; @@ -1374,6 +1413,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "rinegoziazione della crittografia consentita per %@"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Errore di rinegoziazione crittografia"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "Rinegoziazione crittografia fallita."; + /* chat item text */ "encryption re-negotiation required" = "richiesta rinegoziazione della crittografia"; @@ -1404,6 +1449,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Inserisci il server a mano"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Inserisci il nome di questo dispositivo…"; + /* placeholder */ "Enter welcome message…" = "Inserisci il messaggio di benvenuto…"; @@ -1893,6 +1941,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Versione del database incompatibile"; +/* No comment provided by engineer. */ +"Incompatible version" = "Versione incompatibile"; + /* PIN entry */ "Incorrect passcode" = "Codice di accesso errato"; @@ -2028,6 +2079,9 @@ /* No comment provided by engineer. */ "Joining group" = "Ingresso nel gruppo"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Tieni aperta l'app per usarla dal desktop"; + /* No comment provided by engineer. */ "Keep your connections" = "Mantieni le tue connessioni"; @@ -2064,6 +2118,12 @@ /* No comment provided by engineer. */ "Limitations" = "Limitazioni"; +/* No comment provided by engineer. */ +"Linked desktop options" = "Opzioni del desktop collegato"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Desktop collegati"; + /* No comment provided by engineer. */ "LIVE" = "IN DIRETTA"; @@ -2462,6 +2522,9 @@ /* No comment provided by engineer. */ "Paste" = "Incolla"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Incolla l'indirizzo desktop"; + /* No comment provided by engineer. */ "Paste image" = "Incolla immagine"; @@ -2846,6 +2909,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "Scansiona codice QR"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Scansiona codice QR dal desktop"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Scansiona il codice di sicurezza dall'app del tuo contatto."; @@ -2990,6 +3056,9 @@ /* No comment provided by engineer. */ "Servers" = "Server"; +/* No comment provided by engineer. */ +"Session code" = "Codice di sessione"; + /* No comment provided by engineer. */ "Set 1 day" = "Imposta 1 giorno"; @@ -3299,6 +3368,9 @@ /* notification title */ "this contact" = "questo contatto"; +/* No comment provided by engineer. */ +"This device name" = "Il nome di questo dispositivo"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Questo gruppo ha più di %lld membri, le ricevute di consegna non vengono inviate."; @@ -3416,6 +3488,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "A meno che il tuo contatto non abbia eliminato la connessione o che questo link non sia già stato usato, potrebbe essere un errore; per favore segnalalo.\nPer connetterti, chiedi al tuo contatto di creare un altro link di connessione e controlla di avere una connessione di rete stabile."; +/* No comment provided by engineer. */ +"Unlink" = "Scollega"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Scollegare il desktop?"; + /* No comment provided by engineer. */ "Unlock" = "Sblocca"; @@ -3470,6 +3548,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Usa per connessioni nuove"; +/* No comment provided by engineer. */ +"Use from desktop" = "Usa dal desktop"; + /* No comment provided by engineer. */ "Use iOS call interface" = "Usa interfaccia di chiamata iOS"; @@ -3491,12 +3572,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "Utilizzo dei server SimpleX Chat."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Verifica il codice con il desktop"; + +/* No comment provided by engineer. */ +"Verify connection" = "Verifica la connessione"; + /* No comment provided by engineer. */ "Verify connection security" = "Verifica la sicurezza della connessione"; +/* No comment provided by engineer. */ +"Verify connections" = "Verifica le connessioni"; + /* No comment provided by engineer. */ "Verify security code" = "Verifica codice di sicurezza"; diff --git a/apps/ios/it.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/it.lproj/SimpleX--iOS--InfoPlist.strings index fa6a1c2452..8830d77060 100644 --- a/apps/ios/it.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/it.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX usa Face ID per l'autenticazione locale"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX usa l'accesso alla rete locale per consentire di usare il profilo di chat tramite l'app desktop sulla stessa rete."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX ha bisogno dell'accesso al microfono per le chiamate audio e video e per registrare messaggi vocali."; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 302d7f74e9..4366d6cb93 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -43,6 +43,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(nieuw)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(dit apparaat v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -548,6 +554,9 @@ /* No comment provided by engineer. */ "Back" = "Terug"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Onjuist desktopadres"; + /* integrity error chat item */ "bad message hash" = "Onjuiste bericht hash"; @@ -771,6 +780,9 @@ /* No comment provided by engineer. */ "Connect incognito" = "Verbind incognito"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Verbinden met desktop"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "maak verbinding met SimpleX Chat-ontwikkelaars."; @@ -801,9 +813,15 @@ /* No comment provided by engineer. */ "connected" = "verbonden"; +/* No comment provided by engineer. */ +"Connected desktop" = "Verbonden desktop"; + /* rcv group event chat item */ "connected directly" = "direct verbonden"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Verbonden met desktop"; + /* No comment provided by engineer. */ "connecting" = "Verbinden"; @@ -828,6 +846,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Verbinden met server... (fout: %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Verbinding maken met desktop"; + /* chat list item title */ "connecting…" = "Verbinden…"; @@ -846,6 +867,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Verbindingsverzoek verzonden!"; +/* No comment provided by engineer. */ +"Connection terminated" = "Verbinding beëindigd"; + /* No comment provided by engineer. */ "Connection timeout" = "Timeout verbinding"; @@ -1173,6 +1197,15 @@ /* No comment provided by engineer. */ "Description" = "Beschrijving"; +/* No comment provided by engineer. */ +"Desktop address" = "Desktop adres"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "Desktop-app-versie %@ is niet compatibel met deze app."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Desktop apparaten"; + /* No comment provided by engineer. */ "Develop" = "Ontwikkelen"; @@ -1236,9 +1269,15 @@ /* server test step */ "Disconnect" = "verbinding verbreken"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Desktop loskoppelen?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Ontdek en sluit je aan bij groepen"; +/* No comment provided by engineer. */ +"Discover on network" = "Ontdek via netwerk"; + /* No comment provided by engineer. */ "Do it later" = "Doe het later"; @@ -1374,6 +1413,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "versleuteling heronderhandeling toegestaan voor % @"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Fout bij heronderhandeling van codering"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "Opnieuw onderhandelen over de codering is mislukt."; + /* chat item text */ "encryption re-negotiation required" = "heronderhandeling van versleuteling vereist"; @@ -1404,6 +1449,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Voer de server handmatig in"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Voer deze apparaatnaam in…"; + /* placeholder */ "Enter welcome message…" = "Welkomst bericht invoeren…"; @@ -1893,6 +1941,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Incompatibele database versie"; +/* No comment provided by engineer. */ +"Incompatible version" = "Incompatibele versie"; + /* PIN entry */ "Incorrect passcode" = "Onjuiste toegangscode"; @@ -2028,6 +2079,9 @@ /* No comment provided by engineer. */ "Joining group" = "Deel nemen aan groep"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Houd de app geopend om deze vanaf de desktop te gebruiken"; + /* No comment provided by engineer. */ "Keep your connections" = "Behoud uw verbindingen"; @@ -2064,6 +2118,12 @@ /* No comment provided by engineer. */ "Limitations" = "Beperkingen"; +/* No comment provided by engineer. */ +"Linked desktop options" = "Gekoppelde desktop opties"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Gelinkte desktops"; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2462,6 +2522,9 @@ /* No comment provided by engineer. */ "Paste" = "Plakken"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Desktopadres plakken"; + /* No comment provided by engineer. */ "Paste image" = "Afbeelding plakken"; @@ -2846,6 +2909,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "Scan QR-code"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Scan QR-code vanaf uw desktop"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Scan de beveiligingscode van de app van uw contact."; @@ -2990,6 +3056,9 @@ /* No comment provided by engineer. */ "Servers" = "Servers"; +/* No comment provided by engineer. */ +"Session code" = "Sessie code"; + /* No comment provided by engineer. */ "Set 1 day" = "Stel 1 dag in"; @@ -3299,6 +3368,9 @@ /* notification title */ "this contact" = "dit contact"; +/* No comment provided by engineer. */ +"This device name" = "Deze apparaatnaam"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Deze groep heeft meer dan %lld -leden, ontvangstbevestigingen worden niet verzonden."; @@ -3416,6 +3488,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Tenzij uw contact de verbinding heeft verwijderd of deze link al is gebruikt, kan het een bug zijn. Meld het alstublieft.\nOm verbinding te maken, vraagt u uw contact om een andere verbinding link te maken en te controleren of u een stabiele netwerkverbinding heeft."; +/* No comment provided by engineer. */ +"Unlink" = "Ontkoppelen"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Desktop ontkoppelen?"; + /* No comment provided by engineer. */ "Unlock" = "Ontgrendelen"; @@ -3470,6 +3548,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Gebruik voor nieuwe verbindingen"; +/* No comment provided by engineer. */ +"Use from desktop" = "Gebruik vanaf desktop"; + /* No comment provided by engineer. */ "Use iOS call interface" = "De iOS-oproepinterface gebruiken"; @@ -3491,12 +3572,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "SimpleX Chat servers gebruiken."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Code verifiëren met desktop"; + +/* No comment provided by engineer. */ +"Verify connection" = "Controleer de verbinding"; + /* No comment provided by engineer. */ "Verify connection security" = "Controleer de verbindingsbeveiliging"; +/* No comment provided by engineer. */ +"Verify connections" = "Controleer verbindingen"; + /* No comment provided by engineer. */ "Verify security code" = "Controleer de beveiligingscode"; diff --git a/apps/ios/nl.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/nl.lproj/SimpleX--iOS--InfoPlist.strings index 38af6191e8..0b9e594567 100644 --- a/apps/ios/nl.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/nl.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX gebruikt Face-ID voor lokale authenticatie"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX maakt gebruik van lokale netwerktoegang om het gebruik van een gebruikerschatprofiel via de desktop-app op hetzelfde netwerk mogelijk te maken."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX heeft microfoon toegang nodig voor audio en video oproepen en om spraak berichten op te nemen."; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 4bcc51663f..08cc7f9820 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -203,7 +203,7 @@ Nombre y avatar diferentes, aislamiento de transporte. Eliminar contacto Eliminar servidor - Nombre Mostrado + Introduce tu nombre: conectado DISPOSITIVO Contraseña base de datos @@ -229,9 +229,9 @@ Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos. %ds eliminado - ¿Conectar mediante enlace de contacto\? - ¿Conectar mediante enlace de grupo\? - ¿Conectar mediante enlace de invitación\? + ¿Conectar mediante dirección de contacto? + ¿Unirse al grupo? + ¿Conectar mediante enlace de invitación? Conectar conectado conectando @@ -413,7 +413,7 @@ PARA CONSOLA Error al cambiar rol SERVIDORES - Nombre mostrado del grupo: + Introduce un nombre para el grupo: Preferencias de grupo Los miembros del grupo pueden enviar mensajes directos. Los miembros del grupo pueden eliminar mensajes de forma irreversible. @@ -482,7 +482,7 @@ Abrir en aplicación móvil, después pulse Conectar en la aplicación.]]> Marcar como leído Marcar como no leído - Código QR inválido + Código QR no válido ¡Código de seguridad incorrecto! Sintaxis Markdown No @@ -838,7 +838,7 @@ Pulsa el botón Para iniciar un chat nuevo Cambiar servidor de recepción - El grupo está totalmente descentralizado: sólo es visible para los miembros. + Completamente descentralizado: sólo visible para los miembros. Para conectarte mediante enlace ¡Error en prueba del servidor! Algunos servidores no superaron la prueba: @@ -1408,4 +1408,63 @@ \n- mayor rapidez y estabilidad. Enviar mensaje directo conectado directamente + Expandir + Error en renegociación de cifrado + contacto eliminado + Error + Crear grupo + Crear perfil + Escritorio conectado + Nuevo dispositivo móvil + Dirección desktop + Sólo un dispositivo puede funcionar al mismo tiempo + ¿Unirse a tu grupo? + %d mensajes marcados como borrados + ¡El grupo ya existe! + ¡Ya está en proceso de conexión! + Versión incompatible + (nuevo)]]> + Opciones escritorio enlazado + Desktop enlazados + Descubrir en red + , y hay %d eventos más + ¿Conectar vía enlace? + ¡Ya está en proceso de unirse al grupo! + %d mensajes moderados por %s + ¿Conectarte a tí mismo? + Móviles enlazados + Desktop + Conectado al escritorio + Cargando archivo + Conectando a desktop + La renegociación de cifrado ha fallado. + Dispositivo desktop + ¿Corregir el nombre a %s? + Elimina %d mensajes? + Enlazar móvil + ¿Conectar con %1$s? + Bloquear + %d mensajes bloqueados + Bloquear miembro + Móvil conectado + Eliminar y notificar contacto + Grupo abierto + Conexión terminada + (este dispositivo v%s)]]> + ¡Los mensajes de %s serán mostrados! + Introduce el nombre de este dispositivo… + Error + Conectar a desktop + Desconectar + Bloquear miembro? + %d eventos de grupo + ¡Nombre no válido! + Conectado a móvil + Dirección de escritorio incorrecta + Dispositivo + Ruta archivo no valida. + ¿Desconectar desktop? + Los mensajes nuevos de %s estarán ocultos! + La versión de aplicación del desktop %s no es compatible con esta aplicación. + bloqueado \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 3cdd4676b7..093ca05646 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1461,4 +1461,48 @@ bloccato Errore di rinegoziazione crittografia Rinegoziazione crittografia fallita. + Desktop connesso + Nuovo dispositivo mobile + Indirizzo desktop + Solo un dispositivo può funzionare nello stesso momento + Usa dal desktop nell\'app mobile e scansiona il codice QR]]> + Versione incompatibile + (nuovo)]]> + Scollegare il desktop? + Opzioni del desktop collegato + Desktop collegati + Trova nella rete + Questo dispositivo + Cellulari collegati + Desktop + Connesso al desktop + Il nome di questo dispositivo + Caricamento del file + Connessione al desktop + Dispositivi desktop + Collega un cellulare + Usa dal desktop + Cellulare connesso + Codice di sessione + Connessione terminata + (questo dispositivo v%s)]]> + Scollega + Il nome del dispositivo sarà condiviso con il client mobile connesso. + Verifica il codice sul cellulare + Inserisci il nome di questo dispositivo… + Errore + Connetti al desktop + Disconnetti + Connesso al cellulare + Indirizzo desktop errato + Incolla l\'indirizzo desktop + Verifica il codice con il desktop + Scansiona codice QR dal desktop + Dispositivi + Scansiona dal cellulare + Verifica le connessioni + Disconnettere il desktop? + Si prega di attendere mentre il file viene caricato dal cellulare collegato + La versione dell\'app desktop %s non è compatibile con questa app. + Verifica la connessione \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 522b22f7ab..c112844395 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1459,4 +1459,48 @@ U heeft al een verbinding aangevraagd via dit adres! Fout bij heronderhandeling van codering Opnieuw onderhandelen over de codering is mislukt. + Verbonden desktop + Nieuw mobiel apparaat + Desktop adres + Er kan slechts één apparaat tegelijkertijd werken + Gebruik vanaf desktop in de mobiele app en scan de QR-code]]> + Incompatibele versie + (nieuw)]]> + Desktop ontkoppelen? + Gekoppelde desktop opties + Gelinkte desktops + Ontdek via netwerk + Dit apparaat + Gekoppelde mobiele apparaten + Desktop + Verbonden met desktop + Deze apparaatnaam + Het bestand laden + Verbinding maken met desktop + Desktop apparaten + Koppel een mobiel + Gebruik vanaf desktop + Verbonden mobiel + Sessie code + Verbinding beëindigd + (dit apparaat v%s)]]> + Ontkoppelen + De apparaatnaam wordt gedeeld met de verbonden mobiele client. + Verifieer de code op mobiel + Voer deze apparaatnaam in… + Fout + Verbinden met desktop + Verbinding verbreken + Verbonden met mobiel + Onjuist desktopadres + Desktopadres plakken + Code verifiëren met desktop + Scan QR-code vanaf uw desktop + Apparaten + Vanaf mobiel scannen + Controleer verbindingen + Desktop loskoppelen? + Wacht terwijl het bestand wordt geladen vanaf de gekoppelde mobiele telefoon + Desktop-app-versie %s is niet compatibel met deze app. + Controleer de verbinding \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index a4f990455f..e672c189a4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1461,4 +1461,48 @@ 已封禁 加密重协商错误 加密重协商失败了。 + 已连接的桌面 + 新移动设备 + 桌面地址 + 同一时刻只有一台设备可以工作 + 从桌面使用并扫描二维码]]> + 不兼容的版本 + (新)]]> + 取消链接桌面端? + 已链接桌面选项 + 已链接桌面 + 网络发现 + 此设备 + 已链接的移动设备 + 桌面 + 已连接到桌面 + 此设备名称 + 加载文件中 + 正连接到桌面 + 桌面设备 + 链接移动设备 + 从桌面端使用 + 已连接的移动设备 + 会话码 + 连接被终止 + (此设备 v%s)]]> + 取消链接 + 将和已连接的移动设备分享设备名。 + 在移动端验证代码 + 输入此设备名… + 错误 + 连接到桌面 + 断开连接 + 已连接到移动设备 + 糟糕的桌面地址 + 粘贴桌面地址 + 用桌面端验证代码 + 从桌面扫描二维码 + 设备 + 从移动端扫描 + 验证连接 + 断开桌面连接? + 从已链接移动设备加载文件时请稍候片刻 + 桌面应用版本 %s 不兼容此应用。 + 验证连接 \ No newline at end of file From 64520a4cf434040f9e94d9aebf5989d12473feda Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 21:12:43 +0000 Subject: [PATCH 246/294] core: 5.4.0.5, update simplexmq --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- stack.yaml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 4652ee3112..05857ba8f3 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 08410671323c056bbcf1a3f2756aad810b522e25 + tag: 757b7eec81341d8560a326deab303bb6fb6a26a3 source-repository-package type: git diff --git a/package.yaml b/package.yaml index da9de37131..3af5afbd3e 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.4 +version: 5.4.0.5 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index de10eb829a..cf61c63764 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."08410671323c056bbcf1a3f2756aad810b522e25" = "0vqjk4c5vd32y92myv6xr4jhipqza6n08qpii4a0xw6ssm5dgc88"; + "https://github.com/simplex-chat/simplexmq.git"."757b7eec81341d8560a326deab303bb6fb6a26a3" = "0kqnxpyz8v43802fncqxdg6i2ni70yv7jg7a1nbkny1w937fwf40"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d33ffe8b54..e7288e2290 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.4 +version: 5.4.0.5 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/stack.yaml b/stack.yaml index 3006b8a61d..39c96ef75b 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 08410671323c056bbcf1a3f2756aad810b522e25 + commit: 757b7eec81341d8560a326deab303bb6fb6a26a3 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher From c40bfb0f4361dd1228707dfd3b72c5dc8020db20 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 22 Nov 2023 05:49:39 +0800 Subject: [PATCH 247/294] android, desktop: show remote host info (#3423) * android, desktop: show remote host info * hide alerts too --- .../simplex/common/platform/Share.android.kt | 3 - .../chat/simplex/common/model/SimpleXAPI.kt | 10 +- .../views/chatlist/ChatListNavLinkView.kt | 19 +- .../common/views/helpers/AlertManager.kt | 224 +++++++++++------- .../common/views/newchat/ScanToConnectView.kt | 40 +++- .../commonMain/resources/MR/base/strings.xml | 1 + .../kotlin/chat/simplex/common/DesktopApp.kt | 4 +- .../simplex/common/platform/Share.desktop.kt | 1 - .../views/chat/item/ChatItemView.desktop.kt | 1 - 9 files changed, 191 insertions(+), 112 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index eb6ed0bbf8..f0c5ea6941 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -8,15 +8,12 @@ import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler -import androidx.core.content.FileProvider -import androidx.core.net.toUri import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import java.io.BufferedOutputStream import java.io.File import chat.simplex.res.MR -import java.io.ByteArrayOutputStream actual fun ClipboardManager.shareText(text: String) { val sendIntent: Intent = Intent().apply { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 9298388df0..5698e8cb0f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -4,7 +4,6 @@ import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter -import chat.simplex.common.model.ChatModel.remoteHostId import chat.simplex.common.model.ChatModel.updatingChatsMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* @@ -1845,7 +1844,13 @@ object ChatController { switchUIRemoteHost(r.remoteHost.remoteHostId) } is CR.RemoteHostStopped -> { + val disconnectedHost = chatModel.remoteHosts.firstOrNull { it.remoteHostId == r.remoteHostId_ } chatModel.newRemoteHostPairing.value = null + if (disconnectedHost != null) { + showToast( + generalGetString(MR.strings.remote_host_was_disconnected_toast).format(disconnectedHost.hostDeviceName.ifEmpty { disconnectedHost.remoteHostId.toString() }) + ) + } if (chatModel.currentRemoteHost.value != null) { chatModel.currentRemoteHost.value = null switchUIRemoteHost(null) @@ -1968,6 +1973,9 @@ object ChatController { suspend fun switchUIRemoteHost(rhId: Long?) { // TODO lock the switch so that two switches can't run concurrently? chatModel.chatId.value = null + ModalManager.center.closeModals() + ModalManager.end.closeModals() + AlertManager.shared.alertViews.clear() chatModel.currentRemoteHost.value = switchRemoteHost(rhId) reloadRemoteHosts() val user = apiGetActiveUser(rhId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index e121f9ee78..9d662758f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -129,7 +129,7 @@ fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress) - GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() + GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert(rhId) else -> withBGApi { openChat(rhId, ChatInfo.Group(groupInfo), chatModel) } } } @@ -538,7 +538,8 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque Text(generalGetString(MR.strings.reject_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) } } - } + }, + hostDevice = hostDevice(rhId), ) } @@ -644,7 +645,8 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - } + }, + hostDevice = hostDevice(rhId), ) } @@ -654,7 +656,8 @@ suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactI chatModel.updateContact(rhId, contact) AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), - text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) + text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted), + hostDevice = hostDevice(rhId), ) return true } @@ -674,7 +677,8 @@ fun acceptGroupInvitationAlertDialog(rhId: Long?, groupInfo: GroupInfo, chatMode } }, dismissText = generalGetString(MR.strings.delete_verb), - onDismiss = { deleteGroup(rhId, groupInfo, chatModel) } + onDismiss = { deleteGroup(rhId, groupInfo, chatModel) }, + hostDevice = hostDevice(rhId), ) } @@ -700,10 +704,11 @@ fun deleteGroup(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { } } -fun groupInvitationAcceptedAlert() { +fun groupInvitationAcceptedAlert(rhId: Long?) { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.joining_group), - generalGetString(MR.strings.youve_accepted_group_invitation_connecting_to_inviting_group_member) + generalGetString(MR.strings.youve_accepted_group_invitation_connecting_to_inviting_group_member), + hostDevice = hostDevice(rhId), ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index fa9c89384d..287f19ffdb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -1,6 +1,5 @@ package chat.simplex.common.views.helpers -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape @@ -14,10 +13,12 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* +import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource class AlertManager { var alertViews = mutableStateListOf<(@Composable () -> Unit)>() @@ -40,8 +41,11 @@ class AlertManager { AlertDialog( onDismissRequest = this::hideAlert, title = alertTitle(title), - text = alertText(text), - buttons = buttons, + buttons = { + AlertContent(text, null, extraPadding = true) { + buttons() + } + }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) ) } @@ -51,30 +55,16 @@ class AlertManager { title: String, text: AnnotatedString? = null, onDismissRequest: (() -> Unit)? = null, + hostDevice: Pair? = null, buttons: @Composable () -> Unit, ) { showAlert { AlertDialog( onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, - title = { - Text( - title, - Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING), - textAlign = TextAlign.Center, - fontSize = 20.sp - ) - }, + title = alertTitle(title), buttons = { - Column( - Modifier - .padding(bottom = DEFAULT_PADDING) - ) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { - if (text != null) { - Text(text, Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), fontSize = 16.sp, textAlign = TextAlign.Center, color = MaterialTheme.colors.secondary) - } - buttons() - } + AlertContent(text, hostDevice, extraPadding = true) { + buttons() } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) @@ -90,30 +80,32 @@ class AlertManager { dismissText: String = generalGetString(MR.strings.cancel_verb), onDismiss: (() -> Unit)? = null, onDismissRequest: (() -> Unit)? = null, - destructive: Boolean = false + destructive: Boolean = false, + hostDevice: Pair? = null, ) { showAlert { AlertDialog( onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = alertTitle(title), - text = alertText(text), buttons = { - Row ( - Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF), - horizontalArrangement = Arrangement.SpaceBetween - ) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.requestFocus() + AlertContent(text, hostDevice) { + Row( + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceBetween + ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + TextButton(onClick = { + onDismiss?.invoke() + hideAlert() + }) { Text(dismissText) } + TextButton(onClick = { + onConfirm?.invoke() + hideAlert() + }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } - TextButton(onClick = { - onDismiss?.invoke() - hideAlert() - }) { Text(dismissText) } - TextButton(onClick = { - onConfirm?.invoke() - hideAlert() - }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) @@ -135,20 +127,21 @@ class AlertManager { AlertDialog( onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = alertTitle(title), - text = alertText(text), buttons = { - Column( - Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING_HALF).padding(top = DEFAULT_PADDING, bottom = 2.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - TextButton(onClick = { - onDismiss?.invoke() - hideAlert() - }) { Text(dismissText) } - TextButton(onClick = { - onConfirm?.invoke() - hideAlert() - }) { Text(confirmText, color = if (destructive) Color.Red else Color.Unspecified, textAlign = TextAlign.End) } + AlertContent(text, null) { + Column( + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING_HALF).padding(top = DEFAULT_PADDING, bottom = 2.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextButton(onClick = { + onDismiss?.invoke() + hideAlert() + }) { Text(dismissText) } + TextButton(onClick = { + onConfirm?.invoke() + hideAlert() + }) { Text(confirmText, color = if (destructive) Color.Red else Color.Unspecified, textAlign = TextAlign.End) } + } } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) @@ -158,29 +151,31 @@ class AlertManager { fun showAlertMsg( title: String, text: String? = null, - confirmText: String = generalGetString(MR.strings.ok) + confirmText: String = generalGetString(MR.strings.ok), + hostDevice: Pair? = null, ) { showAlert { AlertDialog( onDismissRequest = this::hideAlert, title = alertTitle(title), - text = alertText(text), buttons = { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - Row( - Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF), - horizontalArrangement = Arrangement.Center - ) { - TextButton( - onClick = { - hideAlert() - }, - Modifier.focusRequester(focusRequester) + AlertContent(text, hostDevice, extraPadding = true) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Row( + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.Center ) { - Text(confirmText, color = Color.Unspecified) + TextButton( + onClick = { + hideAlert() + }, + Modifier.focusRequester(focusRequester) + ) { + Text(confirmText, color = Color.Unspecified) + } } } }, @@ -191,16 +186,17 @@ class AlertManager { fun showAlertMsgWithProgress( title: String, - text: String? = null + text: String? = null, ) { showAlert { AlertDialog( onDismissRequest = this::hideAlert, title = alertTitle(title), - text = alertText(text), buttons = { - Box(Modifier.fillMaxWidth().height(72.dp).padding(bottom = DEFAULT_PADDING * 2), contentAlignment = Alignment.Center) { - CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) + AlertContent(text, null) { + Box(Modifier.fillMaxWidth().height(72.dp).padding(bottom = DEFAULT_PADDING * 2), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) + } } } ) @@ -211,7 +207,8 @@ class AlertManager { title: StringResource, text: StringResource? = null, confirmText: StringResource = MR.strings.ok, - ) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText)) + hostDevice: Pair? = null, + ) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), hostDevice) @Composable fun showInView() { @@ -234,18 +231,75 @@ private fun alertTitle(title: String): (@Composable () -> Unit)? { } } -private fun alertText(text: String?): (@Composable () -> Unit)? { - return if (text == null) { - null - } else { - ({ - Text( - escapedHtmlToAnnotatedString(text, LocalDensity.current), - Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 16.sp, - color = MaterialTheme.colors.secondary - ) - }) +@Composable +private fun AlertContent(text: String?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { + Column( + Modifier + .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) + ) { + if (appPlatform.isDesktop) { + HostDeviceTitle(hostDevice, extraPadding = extraPadding) + } else { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + } + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { + if (text != null) { + Text( + escapedHtmlToAnnotatedString(text, LocalDensity.current), + Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.secondary + ) + } + } + content() + } +} + +@Composable +private fun AlertContent(text: AnnotatedString?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { + Column( + Modifier + .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) + ) { + if (appPlatform.isDesktop) { + HostDeviceTitle(hostDevice, extraPadding = extraPadding) + } else { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + } + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { + if (text != null) { + Text( + text, + Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.secondary + ) + } + } + content() + } +} + +fun hostDevice(rhId: Long?): Pair? = if (rhId == null && chatModel.remoteHosts.isNotEmpty()) { + null to ChatModel.controller.appPrefs.deviceNameForRemoteAccess.get()!! +} else if (rhId == null) { + null +} else { + rhId to (chatModel.remoteHosts.firstOrNull { it.remoteHostId == rhId }?.hostDeviceName?.ifEmpty { rhId.toString() } ?: rhId.toString()) +} + +@Composable +private fun HostDeviceTitle(hostDevice: Pair?, extraPadding: Boolean = false) { + if (hostDevice != null) { + Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(10.dp)) + Text(hostDevice.second, color = MaterialTheme.colors.secondary) + } + } else { + Spacer(Modifier.height(if (extraPadding) DEFAULT_PADDING * 2 else 0.dp)) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index bd111c9c32..fd39613629 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -4,7 +4,6 @@ import SectionBottomSpacer import SectionItemView import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview -import chat.simplex.common.platform.Log import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -17,7 +16,7 @@ import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* -import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* @@ -65,6 +64,7 @@ suspend fun planAndConnect( confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -82,12 +82,14 @@ suspend fun planAndConnect( openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), + hostDevice = hostDevice(rhId), ) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connect_plan_already_connecting), - generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link) + generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link), + hostDevice = hostDevice(rhId), ) } } @@ -97,7 +99,8 @@ suspend fun planAndConnect( openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), + hostDevice = hostDevice(rhId), ) } } @@ -124,6 +127,7 @@ suspend fun planAndConnect( confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -143,6 +147,7 @@ suspend fun planAndConnect( confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -159,7 +164,8 @@ suspend fun planAndConnect( openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), + hostDevice = hostDevice(rhId), ) } is ContactAddressPlan.Known -> { @@ -168,7 +174,8 @@ suspend fun planAndConnect( openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), + hostDevice = hostDevice(rhId), ) } is ContactAddressPlan.ContactViaAddress -> { @@ -190,7 +197,8 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } } + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -215,6 +223,7 @@ suspend fun planAndConnect( confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, + hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( @@ -236,7 +245,8 @@ suspend fun planAndConnect( } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connect_plan_already_joining_the_group), - generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), + hostDevice = hostDevice(rhId), ) } } @@ -246,7 +256,8 @@ suspend fun planAndConnect( openKnownGroup(chatModel, rhId, close, groupInfo) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName) + String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName), + hostDevice = hostDevice(rhId), ) } } @@ -284,7 +295,8 @@ suspend fun connectViaUri( ConnectionLinkType.CONTACT -> generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) ConnectionLinkType.INVITATION -> generalGetString(MR.strings.you_will_be_connected_when_your_contacts_device_is_online) ConnectionLinkType.GROUP -> generalGetString(MR.strings.you_will_be_connected_when_group_host_device_is_online) - } + }, + hostDevice = hostDevice(rhId), ) } return r @@ -336,7 +348,8 @@ fun askCurrentOrIncognitoProfileAlert( Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - } + }, + hostDevice = hostDevice(rhId), ) } @@ -411,7 +424,8 @@ fun ownGroupLinkConfirmConnect( Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - } + }, + hostDevice = hostDevice(rhId), ) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 177dcb55f7..55cf4e6c98 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1668,6 +1668,7 @@ Unlink desktop? Unlink Disconnect + %s]]> Disconnect desktop? Only one device can work at the same time Use from desktop in mobile app and scan QR code]]> diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 2931e0e014..1a95317a6a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.ChatController @@ -19,6 +20,7 @@ import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.FileDialogChooser +import chat.simplex.common.views.helpers.escapedHtmlToAnnotatedString import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* @@ -80,7 +82,7 @@ fun showApp() = application { if (toast != null) { Box(Modifier.fillMaxSize().padding(bottom = 20.dp), contentAlignment = Alignment.BottomCenter) { Text( - toast.first, + escapedHtmlToAnnotatedString(toast.first, LocalDensity.current), Modifier.background(MaterialTheme.colors.primary, RoundedCornerShape(100)).padding(vertical = 5.dp, horizontal = 10.dp), color = MaterialTheme.colors.onPrimary, style = MaterialTheme.typography.body1 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt index a91bc5a761..f7728f9c63 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -4,7 +4,6 @@ import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.AnnotatedString import chat.simplex.common.model.* -import chat.simplex.common.views.helpers.getAppFileUri import chat.simplex.common.views.helpers.withApi import java.io.File import java.net.URI diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index 91efdf7908..905a6e3520 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -19,7 +19,6 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.io.File -import java.util.* @Composable actual fun ReactionIcon(text: String, fontSize: TextUnit) { From c1d89f2c0f27497da922fb596ba9e9099d004eb0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 22:21:01 +0000 Subject: [PATCH 248/294] ios: move hs_init to background thread (#3411) --- apps/ios/Shared/SimpleXApp.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 13e681ae25..f030f105bf 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -26,7 +26,9 @@ struct SimpleXApp: App { @State private var showInitializationView = false init() { - hs_init(0, nil) + DispatchQueue.global(qos: .background).sync { + hs_init(0, nil) + } UserDefaults.standard.register(defaults: appDefaults) setGroupDefaults() registerGroupDefaults() From aade3d359f0cf56e223a76a1abd7e6d3edaf6366 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 21 Nov 2023 23:32:33 +0000 Subject: [PATCH 249/294] v5.4-beta.4: ios 182, android 161, desktop 17 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 52 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e98704d309..d2456b6966 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -118,11 +118,6 @@ 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; - 5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A282B04FE2D00A71D61 /* libgmp.a */; }; - 5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */; }; - 5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */; }; - 5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */; }; - 5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -148,6 +143,11 @@ 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; + 5CF077FB2B0D60C100105111 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077F62B0D60C000105111 /* libgmpxx.a */; }; + 5CF077FC2B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077F72B0D60C000105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a */; }; + 5CF077FD2B0D60C100105111 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077F82B0D60C000105111 /* libgmp.a */; }; + 5CF077FE2B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077F92B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a */; }; + 5CF077FF2B0D60C100105111 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF077FA2B0D60C100105111 /* libffi.a */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -399,11 +399,6 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; - 5CDA5A282B04FE2D00A71D61 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a"; sourceTree = ""; }; - 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a"; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -430,6 +425,11 @@ 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; + 5CF077F62B0D60C000105111 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CF077F72B0D60C000105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a"; sourceTree = ""; }; + 5CF077F82B0D60C000105111 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CF077F92B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a"; sourceTree = ""; }; + 5CF077FA2B0D60C100105111 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -507,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */, + 5CF077FB2B0D60C100105111 /* libgmpxx.a in Frameworks */, + 5CF077FD2B0D60C100105111 /* libgmp.a in Frameworks */, + 5CF077FE2B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */, - 5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */, - 5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */, + 5CF077FF2B0D60C100105111 /* libffi.a in Frameworks */, + 5CF077FC2B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -575,11 +575,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */, - 5CDA5A282B04FE2D00A71D61 /* libgmp.a */, - 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */, - 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */, - 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */, + 5CF077FA2B0D60C100105111 /* libffi.a */, + 5CF077F82B0D60C000105111 /* libgmp.a */, + 5CF077F62B0D60C000105111 /* libgmpxx.a */, + 5CF077F92B0D60C100105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2-ghc9.6.3.a */, + 5CF077F72B0D60C000105111 /* libHSsimplex-chat-5.4.0.5-AEaxUB19STC3bOtqr9BLL2.a */, ); path = Libraries; sourceTree = ""; @@ -1494,7 +1494,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1537,7 +1537,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1618,7 +1618,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1650,7 +1650,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1682,7 +1682,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1728,7 +1728,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 181; + CURRENT_PROJECT_VERSION = 182; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b16636d0d6..9c817f08f4 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4-beta.3 -android.version_code=160 +android.version_name=5.4-beta.4 +android.version_code=161 -desktop.version_name=5.4-beta.3 -desktop.version_code=16 +desktop.version_name=5.4-beta.4 +desktop.version_code=17 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From 69acac53317c89f7b6b4770aae453c68d6df02fa Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:19:39 +0400 Subject: [PATCH 250/294] android: remove unused strings (#3424) --- .../src/commonMain/resources/MR/base/strings.xml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 55cf4e6c98..0509bc1ebe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1608,16 +1608,6 @@ Toggle incognito when connecting. 6 new interface languages Arabic, Bulgarian, Finnish, Hebrew, Thai and Ukrainian - thanks to the users and Weblate. - Connect desktop and mobile! - Use your mobile app chat profile via desktop app. - Group improvements - Create groups incognito, block group members, faster join via link, and more. - Notify about contact deletion - You can optionally notify contacts when deleting them. - Checking SimpleX links - Detection of previously used and your own SimpleX links. - Spaces in profile names - You can now add spaces to your profile name. Link mobile and desktop apps! 🔗 Via secure quantum resistant protocol. Better groups From 9442121efa12e51770debfd9356255d5290f0bdb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 12:14:49 +0000 Subject: [PATCH 251/294] desktop: do not switch remote host when inactive host is disconnected (#3426) --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 5698e8cb0f..4aed002619 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1851,7 +1851,7 @@ object ChatController { generalGetString(MR.strings.remote_host_was_disconnected_toast).format(disconnectedHost.hostDeviceName.ifEmpty { disconnectedHost.remoteHostId.toString() }) ) } - if (chatModel.currentRemoteHost.value != null) { + if (chatModel.remoteHostId == r.remoteHostId_) { chatModel.currentRemoteHost.value = null switchUIRemoteHost(null) } From 48d7afc959ced8984c62ae034b25f1fed391c50f Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:35:32 +0800 Subject: [PATCH 252/294] desktop: enhancements to remote hosts experience (#3428) --- .../java/chat/simplex/app/MainActivity.kt | 2 +- .../main/java/chat/simplex/app/SimplexApp.kt | 2 +- .../chat/simplex/common/model/ChatModel.kt | 3 +- .../chat/simplex/common/model/SimpleXAPI.kt | 4 +- .../chat/simplex/common/views/TerminalView.kt | 2 +- .../chat/simplex/common/views/WelcomeView.kt | 2 +- .../simplex/common/views/chat/ScanCodeView.kt | 2 +- .../common/views/chat/VerifyCodeView.kt | 2 +- .../common/views/chatlist/ChatListView.kt | 2 +- .../common/views/chatlist/UserPicker.kt | 23 +++++++++-- .../common/views/database/DatabaseView.kt | 2 +- .../common/views/helpers/AlertManager.kt | 2 +- .../common/views/helpers/CloseSheetBar.kt | 39 +++++++++++++------ .../simplex/common/views/helpers/Utils.kt | 2 +- .../common/views/newchat/AddContactView.kt | 2 +- .../common/views/newchat/AddGroupView.kt | 7 +++- .../newchat/ContactConnectionInfoView.kt | 6 ++- .../common/views/newchat/PasteToConnect.kt | 2 +- .../common/views/newchat/ScanToConnectView.kt | 2 +- .../common/views/onboarding/HowItWorks.kt | 2 +- .../common/views/remote/ConnectMobileView.kt | 10 ++--- .../common/views/usersettings/HelpView.kt | 2 +- .../views/usersettings/ProtocolServersView.kt | 16 +++++--- .../views/usersettings/ScanProtocolServer.kt | 2 +- .../common/views/usersettings/SettingsView.kt | 2 +- .../views/usersettings/UserAddressView.kt | 6 ++- .../views/usersettings/VersionInfoView.kt | 2 +- 27 files changed, 99 insertions(+), 51 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 4c5d595a83..a84590fb85 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -126,7 +126,7 @@ fun processIntent(intent: Intent?) { when (intent?.action) { "android.intent.action.VIEW" -> { val uri = intent.data - if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId, uri.toURI(), ChatModel) + if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId(), uri.toURI(), ChatModel) } } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 13908f69bf..b6afab4eaa 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -57,7 +57,7 @@ class SimplexApp: Application(), LifecycleEventObserver { updatingChatsMutex.withLock { kotlin.runCatching { val currentUserId = chatModel.currentUser.value?.userId - val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId)) + val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId())) /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ if (chatModel.currentUser.value?.userId == currentUserId) { val currentChatId = chatModel.chatId.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 660e7de151..dca3bc5b1a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -111,7 +111,8 @@ object ChatModel { // remote controller val remoteHosts = mutableStateListOf() val currentRemoteHost = mutableStateOf(null) - val remoteHostId: Long? get() = currentRemoteHost?.value?.remoteHostId + val remoteHostId: Long? @Composable get() = remember { currentRemoteHost }.value?.remoteHostId + fun remoteHostId(): Long? = currentRemoteHost.value?.remoteHostId val newRemoteHostPairing = mutableStateOf?>(null) val remoteCtrlSession = mutableStateOf(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 4aed002619..26595880db 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1625,7 +1625,7 @@ object ChatController { || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { withApi { receiveFile(rhId, r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) } } - if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId != rhId)) { + if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } @@ -1913,7 +1913,7 @@ object ChatController { } private fun activeUser(rhId: Long?, user: UserLike): Boolean = - rhId == chatModel.remoteHostId && user.userId == chatModel.currentUser.value?.userId + rhId == chatModel.remoteHostId() && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { val call = chatModel.activeCall.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index ec2082557a..59f62f9c92 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -54,7 +54,7 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState Unit) { fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { - val rhId = chatModel.remoteHostId + val rhId = chatModel.remoteHostId() val user = chatModel.controller.apiCreateActiveUser( rhId, Profile(displayName.trim(), "", null) ) ?: return@withApi diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 8ce39eea35..bb479d8eb3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -17,7 +17,7 @@ fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () .fillMaxSize() .padding(horizontal = DEFAULT_PADDING) ) { - AppBarTitle(stringResource(MR.strings.scan_code), false) + AppBarTitle(stringResource(MR.strings.scan_code), withPadding = false) Box( Modifier .fillMaxWidth() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index 1b25c52cfe..e1840dd885 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -63,7 +63,7 @@ private fun VerifyCodeLayout( .verticalScroll(rememberScrollState()) .padding(horizontal = DEFAULT_PADDING) ) { - AppBarTitle(stringResource(MR.strings.security_code), false) + AppBarTitle(stringResource(MR.strings.security_code), withPadding = false) val splitCode = splitToParts(connectionCode, 24) Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) { if (connectionVerified) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index f9502bf908..301f090336 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -53,7 +53,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val url = chatModel.appOpenUrl.value if (url != null) { chatModel.appOpenUrl.value = null - connectIfOpenedViaUri(chatModel.remoteHostId, url, chatModel) + connectIfOpenedViaUri(chatModel.remoteHostId(), url, chatModel) } } if (appPlatform.isDesktop) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 20e856df6e..46a333f61c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -26,8 +26,7 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* -import chat.simplex.common.views.remote.ConnectDesktopView -import chat.simplex.common.views.remote.connectMobileDevice +import chat.simplex.common.views.remote.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay @@ -84,7 +83,7 @@ fun UserPicker( .filter { it } .collect { try { - val updatedUsers = chatModel.controller.listUsers(chatModel.remoteHostId).sortedByDescending { it.user.activeUser } + val updatedUsers = chatModel.controller.listUsers(chatModel.remoteHostId()).sortedByDescending { it.user.activeUser } var same = users.size == updatedUsers.size if (same) { for (i in 0 until minOf(users.size, updatedUsers.size)) { @@ -213,6 +212,14 @@ fun UserPicker( userPickerState.value = AnimatedViewState.GONE } Divider(Modifier.requiredHeight(1.dp)) + } else if (remoteHosts.isEmpty()) { + LinkAMobilePickerItem { + ModalManager.start.showModal { + ConnectMobileView() + } + userPickerState.value = AnimatedViewState.GONE + } + Divider(Modifier.requiredHeight(1.dp)) } if (showSettings) { SettingsPickerItem(settingsClicked) @@ -384,6 +391,16 @@ private fun UseFromDesktopPickerItem(onClick: () -> Unit) { } } +@Composable +private fun LinkAMobilePickerItem(onClick: () -> Unit) { + SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { + val text = generalGetString(MR.strings.link_a_mobile) + Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) + } +} + @Composable private fun SettingsPickerItem(onClick: () -> Unit) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 1f4be29664..bc43310b8e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -627,7 +627,7 @@ private fun afterSetCiTTL( try { updatingChatsMutex.withLock { // this is using current remote host on purpose - if it changes during update, it will load correct chats - val chats = m.controller.apiGetChats(m.remoteHostId) + val chats = m.controller.apiGetChats(m.remoteHostId()) m.updateChats(chats) } } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 287f19ffdb..10bbae6bfc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -88,7 +88,7 @@ class AlertManager { onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = alertTitle(title), buttons = { - AlertContent(text, hostDevice) { + AlertContent(text, hostDevice, true) { Row( Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceBetween diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt index 848b21eb44..1a29a334a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt @@ -14,6 +14,8 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource @Composable fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}) { @@ -47,23 +49,38 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @ } @Composable -fun AppBarTitle(title: String, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f) { +fun AppBarTitle(title: String, hostDevice: Pair? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f) { val theme = CurrentColors.collectAsState() val titleColor = CurrentColors.collectAsState().value.appColors.title val brush = if (theme.value.base == DefaultTheme.SIMPLEX) Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f)) else // color is not updated when changing themes if I pass null here Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f)) - Text( - title, - Modifier - .fillMaxWidth() - .padding(bottom = bottomPadding, start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp,), - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.h1.copy(brush = brush), - color = MaterialTheme.colors.primaryVariant, - textAlign = TextAlign.Center - ) + Column { + Text( + title, + Modifier + .fillMaxWidth() + .padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp,), + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h1.copy(brush = brush), + color = MaterialTheme.colors.primaryVariant, + textAlign = TextAlign.Center + ) + if (hostDevice != null) { + HostDeviceTitle(hostDevice) + } + Spacer(Modifier.height(bottomPadding)) + } +} + +@Composable +private fun HostDeviceTitle(hostDevice: Pair, extraPadding: Boolean = false) { + Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(10.dp)) + Text(hostDevice.second, color = MaterialTheme.colors.secondary) + } } @Preview/*( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index dbadec32f1..21b71d876b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -373,7 +373,7 @@ inline fun serializableSaver(): Saver = Saver( fun UriHandler.openVerifiedSimplexUri(uri: String) { val URI = try { URI.create(uri) } catch (e: Exception) { null } if (URI != null) { - connectIfOpenedViaUri(chatModel.remoteHostId, URI, ChatModel) + connectIfOpenedViaUri(chatModel.remoteHostId(), URI, ChatModel) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index a539bf4984..84080d5b97 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -79,7 +79,7 @@ fun AddContactLayout( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.SpaceBetween, ) { - AppBarTitle(stringResource(MR.strings.add_contact)) + AppBarTitle(stringResource(MR.strings.add_contact), hostDevice(rh?.remoteHostId)) SectionView(stringResource(MR.strings.one_time_link_short).uppercase()) { if (connReq.isNotEmpty()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index aa5494e5a9..4f71e81b0d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -58,6 +58,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { } }, incognitoPref = chatModel.controller.appPrefs.incognito, + rhId, close ) } @@ -66,6 +67,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { fun AddGroupLayout( createGroup: (Boolean, GroupProfile) -> Unit, incognitoPref: SharedPreference, + rhId: Long?, close: () -> Unit ) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) @@ -98,7 +100,7 @@ fun AddGroupLayout( .verticalScroll(rememberScrollState()) .padding(horizontal = DEFAULT_PADDING) ) { - AppBarTitle(stringResource(MR.strings.create_secret_group_title)) + AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId)) Box( Modifier .fillMaxWidth() @@ -174,7 +176,8 @@ fun PreviewAddGroupLayout() { AddGroupLayout( createGroup = { _, _ -> }, incognitoPref = SharedPreference({ false }, {}), - close = {} + close = {}, + rhId = null, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 50419cdaff..5e9495e866 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -56,6 +56,7 @@ fun ContactConnectionInfoView( connReq = connReqInvitation, contactConnection = contactConnection, focusAlias = focusAlias, + rhId = rhId, deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, @@ -80,6 +81,7 @@ private fun ContactConnectionInfoLayout( connReq: String?, contactConnection: PendingContactConnection, focusAlias: Boolean, + rhId: Long?, deleteConnection: () -> Unit, onLocalAliasChanged: (String) -> Unit, share: () -> Unit, @@ -114,7 +116,8 @@ private fun ContactConnectionInfoLayout( stringResource( if (contactConnection.initiated) MR.strings.you_invited_a_contact else MR.strings.you_accepted_connection - ) + ), + hostDevice(rhId) ) Text( stringResource( @@ -185,6 +188,7 @@ private fun PreviewContactConnectionInfoView() { connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", contactConnection = PendingContactConnection.getSampleData(), focusAlias = false, + rhId = null, deleteConnection = {}, onLocalAliasChanged = {}, share = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index 3e4447d355..dacf937575 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -67,7 +67,7 @@ fun PasteToConnectLayout( Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), verticalArrangement = Arrangement.SpaceBetween, ) { - AppBarTitle(stringResource(MR.strings.connect_via_link), false) + AppBarTitle(stringResource(MR.strings.connect_via_link), hostDevice(rhId), withPadding = false) Box(Modifier.padding(top = DEFAULT_PADDING, bottom = 6.dp)) { TextEditor( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index fd39613629..4deeda3e24 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -469,7 +469,7 @@ fun ConnectContactLayout( Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), verticalArrangement = Arrangement.SpaceBetween ) { - AppBarTitle(stringResource(MR.strings.scan_QR_code), false) + AppBarTitle(stringResource(MR.strings.scan_QR_code), hostDevice(rh?.remoteHostId), withPadding = false) Box( Modifier .fillMaxWidth() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index e3dfb2b736..6c76acc3e8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -26,7 +26,7 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference? .fillMaxWidth() .padding(horizontal = DEFAULT_PADDING), ) { - AppBarTitle(stringResource(MR.strings.how_simplex_works), false) + AppBarTitle(stringResource(MR.strings.how_simplex_works), withPadding = false) ReadableText(MR.strings.many_people_asked_how_can_it_deliver) ReadableText(MR.strings.to_protect_privacy_simplex_has_ids_for_queues) ReadableText(MR.strings.you_control_servers_to_receive_your_contacts_to_send) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 0d90e59450..974f71cf2c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -36,12 +36,10 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -fun ConnectMobileView( - m: ChatModel -) { +fun ConnectMobileView() { val connecting = rememberSaveable() { mutableStateOf(false) } val remoteHosts = remember { chatModel.remoteHosts } - val deviceName = m.controller.appPrefs.deviceNameForRemoteAccess + val deviceName = chatModel.controller.appPrefs.deviceNameForRemoteAccess LaunchedEffect(Unit) { controller.reloadRemoteHosts() } @@ -49,11 +47,11 @@ fun ConnectMobileView( deviceName = remember { deviceName.state }, remoteHosts = remoteHosts, connecting, - connectedHost = remember { m.currentRemoteHost }, + connectedHost = remember { chatModel.currentRemoteHost }, updateDeviceName = { withBGApi { if (it != "") { - m.controller.setLocalDeviceName(it) + chatModel.controller.setLocalDeviceName(it) deviceName.set(it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt index c5bde44947..33e183aaaf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt @@ -26,7 +26,7 @@ fun HelpLayout(userDisplayName: String) { .verticalScroll(rememberScrollState()) .padding(horizontal = DEFAULT_PADDING), ){ - AppBarTitle(String.format(stringResource(MR.strings.personal_welcome), userDisplayName), false) + AppBarTitle(String.format(stringResource(MR.strings.personal_welcome), userDisplayName), withPadding = false) ChatHelpView() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt index cbcb7344f5..66dde9f96c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt @@ -29,13 +29,12 @@ import kotlinx.coroutines.launch @Composable fun ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) { - // TODO close if remote host changes - var presetServers by remember { mutableStateOf(emptyList()) } - var servers by remember { + var presetServers by remember(rhId) { mutableStateOf(emptyList()) } + var servers by remember(rhId) { mutableStateOf(m.userSMPServersUnsaved.value ?: emptyList()) } - val currServers = remember { mutableStateOf(servers) } - val testing = rememberSaveable { mutableStateOf(false) } + val currServers = remember(rhId) { mutableStateOf(servers) } + val testing = rememberSaveable(rhId) { mutableStateOf(false) } val serversUnchanged = remember { derivedStateOf { servers == currServers.value || testing.value } } val allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } } val saveDisabled = remember { @@ -51,7 +50,12 @@ fun ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtoco } } - LaunchedEffect(Unit) { + KeyChangeEffect(rhId) { + m.userSMPServersUnsaved.value = null + servers = emptyList() + } + + LaunchedEffect(rhId) { val res = m.controller.getUserProtoServers(rhId, serverProtocol) if (res != null) { currServers.value = res.protoServers diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt index ac74bd04d8..77cb0ead13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt @@ -22,7 +22,7 @@ fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) { .fillMaxSize() .padding(horizontal = DEFAULT_PADDING) ) { - AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr), false) + AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr), withPadding = false) Box( Modifier .fillMaxWidth() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 7bd060e8d6..4e86d33a4e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -158,7 +158,7 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, it.currentUser.value?.remoteHostId, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) ChatPreferencesItem(showCustomModal, stopped = stopped) if (appPlatform.isDesktop) { - SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView(it) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped, extraPadding = true) } else { SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 98989a775a..d248241086 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -65,6 +65,7 @@ fun UserAddressView( UserAddressLayout( userAddress = userAddress.value, shareViaProfile, + rhId, onCloseHandler, createAddress = { withApi { @@ -169,6 +170,7 @@ fun UserAddressView( private fun UserAddressLayout( userAddress: UserContactLinkRec?, shareViaProfile: MutableState, + rhId: Long?, onCloseHandler: MutableState<(close: () -> Unit) -> Unit>, createAddress: () -> Unit, learnMore: () -> Unit, @@ -181,7 +183,7 @@ private fun UserAddressLayout( Column( Modifier.verticalScroll(rememberScrollState()), ) { - AppBarTitle(stringResource(MR.strings.simplex_address), false) + AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(rhId), withPadding = false) Column( Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalAlignment = Alignment.CenterHorizontally, @@ -438,6 +440,7 @@ fun PreviewUserAddressLayoutNoAddress() { setProfileAddress = { _ -> }, learnMore = {}, shareViaProfile = remember { mutableStateOf(false) }, + rhId = null, onCloseHandler = remember { mutableStateOf({}) }, sendEmail = {}, ) @@ -471,6 +474,7 @@ fun PreviewUserAddressLayoutAddressCreated() { setProfileAddress = { _ -> }, learnMore = {}, shareViaProfile = remember { mutableStateOf(false) }, + rhId = null, onCloseHandler = remember { mutableStateOf({}) }, sendEmail = {}, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index 010b94e034..06a4762210 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -18,7 +18,7 @@ fun VersionInfoView(info: CoreVersionInfo) { Column( Modifier.padding(horizontal = DEFAULT_PADDING), ) { - AppBarTitle(stringResource(MR.strings.app_version_title), false) + AppBarTitle(stringResource(MR.strings.app_version_title), withPadding = false) if (appPlatform.isAndroid) { Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.ANDROID_VERSION_NAME)) Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE)) From cec0fe2702eea0af84a43ad56541d6205e448dae Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 22 Nov 2023 18:47:46 +0400 Subject: [PATCH 253/294] ios, android: add author group member role to fix decoding (hidden from UI) (#3429) --- .../Shared/Views/Chat/Group/AddGroupMembersView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 11 +++++++---- .../kotlin/chat/simplex/common/model/ChatModel.kt | 4 +++- .../common/views/chat/group/AddGroupMembersView.kt | 4 +++- .../src/commonMain/resources/MR/base/strings.xml | 1 + 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index d206b9b418..b89c006c61 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -157,7 +157,7 @@ struct AddGroupMembersViewCommon: View { private func rolePicker() -> some View { Picker("New member role", selection: $selectedRole) { ForEach(GroupMemberRole.allCases) { role in - if role <= groupInfo.membership.memberRole { + if role <= groupInfo.membership.memberRole && role != .author { Text(role.text) } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 551ed2794d..dc4cdda462 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1847,7 +1847,7 @@ public struct GroupMember: Identifiable, Decodable { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { if !canBeRemoved(groupInfo: groupInfo) { return nil } let userRole = groupInfo.membership.memberRole - return GroupMemberRole.allCases.filter { $0 <= userRole } + return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .author } } public var memberIncognito: Bool { @@ -1887,6 +1887,7 @@ public struct GroupMemberIds: Decodable { public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { case observer = "observer" + case author = "author" case member = "member" case admin = "admin" case owner = "owner" @@ -1896,6 +1897,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec public var text: String { switch self { case .observer: return NSLocalizedString("observer", comment: "member role") + case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") @@ -1905,9 +1907,10 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec private var comparisonValue: Int { switch self { case .observer: return 0 - case .member: return 1 - case .admin: return 2 - case .owner: return 3 + case .author: return 1 + case .member: return 2 + case .admin: return 3 + case .owner: return 4 } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index dca3bc5b1a..7541b7f34f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1253,7 +1253,7 @@ data class GroupMember ( fun canChangeRoleTo(groupInfo: GroupInfo): List? = if (!canBeRemoved(groupInfo)) null else groupInfo.membership.memberRole.let { userRole -> - GroupMemberRole.values().filter { it <= userRole } + GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Author } } val memberIncognito = memberProfile.profileId != memberContactProfileId @@ -1295,12 +1295,14 @@ data class GroupMemberIds( @Serializable enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons + @SerialName("author") Author("author"), @SerialName("member") Member("member"), @SerialName("admin") Admin("admin"), @SerialName("owner") Owner("owner"); val text: String get() = when (this) { Observer -> generalGetString(MR.strings.group_member_role_observer) + Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) Admin -> generalGetString(MR.strings.group_member_role_admin) Owner -> generalGetString(MR.strings.group_member_role_owner) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index ff23d40b82..37ee9729f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -205,7 +205,9 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState observer + author member admin owner From 324f614e00bcdfae45e6e51a51304b4ccb427ea5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 17:40:10 +0000 Subject: [PATCH 254/294] core: return remote controller port to UI (#3430) --- src/Simplex/Chat.hs | 5 +++-- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/View.hs | 6 ++++-- tests/RemoteTests.hs | 8 ++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 06a603584b..b67fa1f3dd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -104,6 +104,7 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version +import Simplex.RemoteControl.Invitation (RCSignedInvitation (..), RCInvitation (..)) import System.Exit (ExitCode, exitFailure, exitSuccess) import System.FilePath (takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) @@ -1966,8 +1967,8 @@ processChatCommand = \case ListRemoteHosts -> withUser_ $ CRRemoteHostList <$> listRemoteHosts SwitchRemoteHost rh_ -> withUser_ $ CRCurrentRemoteHost <$> switchRemoteHost rh_ StartRemoteHost rh_ -> withUser_ $ do - (remoteHost_, inv) <- startRemoteHost rh_ - pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv} + (remoteHost_, inv@RCSignedInvitation {invitation = RCInvitation {port}}) <- startRemoteHost rh_ + pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv, ctrlPort = show port} StopRemoteHost rh_ -> withUser_ $ closeRemoteHost rh_ >> ok_ DeleteRemoteHost rh -> withUser_ $ deleteRemoteHost rh >> ok_ StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7533649c69..e470a6fd89 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -657,7 +657,7 @@ data ChatResponse | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} - | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text} + | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text, ctrlPort :: String} | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index fc37569fdd..07664b9539 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -285,11 +285,13 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe rhi_ ] CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostStarted {remoteHost_, invitation} -> - [ maybe "new remote host started" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " started") remoteHost_, + CRRemoteHostStarted {remoteHost_, invitation, ctrlPort} -> + [ plain $ maybe ("new remote host" <> started) (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> show rhId <> started) remoteHost_, "Remote session invitation:", plain invitation ] + where + started = " started on port " <> ctrlPort CRRemoteHostSessionCode {remoteHost_, sessionCode} -> [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, "Compare session code with host:", diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index b7b34999b0..9ede37f9a4 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -116,7 +116,7 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil mobileBob ##> "/set device name MobileBob" mobileBob <## "ok" desktop ##> "/start remote host 1" - desktop <## "remote host 1 started" + desktop <##. "remote host 1 started on port " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobileBob ##> ("/connect remote ctrl " <> inv) @@ -425,7 +425,7 @@ startRemote mobile desktop = do mobile ##> "/set device name Mobile" mobile <## "ok" desktop ##> "/start remote host new" - desktop <## "new remote host started" + desktop <##. "new remote host started on port " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -440,7 +440,7 @@ startRemote mobile desktop = do startRemoteStored :: TestCC -> TestCC -> IO () startRemoteStored mobile desktop = do desktop ##> "/start remote host 1" - desktop <## "remote host 1 started" + desktop <##. "remote host 1 started on port " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -454,7 +454,7 @@ startRemoteStored mobile desktop = do startRemoteDiscover :: TestCC -> TestCC -> IO () startRemoteDiscover mobile desktop = do desktop ##> "/start remote host 1 multicast=on" - desktop <## "remote host 1 started" + desktop <##. "remote host 1 started on port " desktop <## "Remote session invitation:" _inv <- getTermLine desktop -- will use multicast instead mobile ##> "/find remote ctrl" From 0c1d78ab08e055ddbf9e2984681e3f52979369c8 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 23 Nov 2023 04:17:05 +0800 Subject: [PATCH 255/294] desktop: specifying port in connect remote host page (#3432) * desktop: specifying port in connect remote host page * shorter string --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/SimpleXAPI.kt | 10 +++++----- .../chat/simplex/common/views/helpers/Utils.kt | 2 +- .../common/views/remote/ConnectMobileView.kt | 17 +++++++++++++---- .../commonMain/resources/MR/base/strings.xml | 3 ++- .../src/commonMain/resources/MR/it/strings.xml | 2 +- .../src/commonMain/resources/MR/nl/strings.xml | 2 +- .../commonMain/resources/MR/zh-rCN/strings.xml | 2 +- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 26595880db..bfcec69da8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1392,10 +1392,10 @@ object ChatController { chatModel.remoteHosts.addAll(hosts) } - suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Pair? { + suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Triple? { val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast)) - if (r is CR.RemoteHostStarted) return r.remoteHost_ to r.invitation - apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) + if (r is CR.RemoteHostStarted) return Triple(r.remoteHost_, r.invitation, r.ctrlPort) + apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r) return null } @@ -1851,7 +1851,7 @@ object ChatController { generalGetString(MR.strings.remote_host_was_disconnected_toast).format(disconnectedHost.hostDeviceName.ifEmpty { disconnectedHost.remoteHostId.toString() }) ) } - if (chatModel.remoteHostId == r.remoteHostId_) { + if (chatModel.remoteHostId() == r.remoteHostId_) { chatModel.currentRemoteHost.value = null switchUIRemoteHost(null) } @@ -3774,7 +3774,7 @@ sealed class CR { // remote events (desktop) @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() @Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR() - @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String): CR() + @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String, val ctrlPort: String): CR() @Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR() @Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 21b71d876b..6e12681dd2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -56,7 +56,7 @@ fun annotatedStringResource(id: StringResource): AnnotatedString { fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedString { val density = LocalDensity.current return remember(id) { - escapedHtmlToAnnotatedString(id.localized().format(args), density) + escapedHtmlToAnnotatedString(id.localized().format(args = args), density) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 974f71cf2c..163d15ce62 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -161,7 +161,8 @@ private fun ConnectMobileViewLayout( title: String, invitation: String?, deviceName: String?, - sessionCode: String? + sessionCode: String?, + port: String? ) { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), @@ -169,13 +170,14 @@ private fun ConnectMobileViewLayout( ) { AppBarTitle(title) SectionView { - if (invitation != null && sessionCode == null) { + if (invitation != null && sessionCode == null && port != null) { QRCode( invitation, Modifier .padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF) .aspectRatio(1f) ) SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code)) + SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port)) if (remember { controller.appPrefs.developerTools.state }.value) { val clipboard = LocalClipboardManager.current @@ -232,6 +234,7 @@ fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { private fun showAddingMobileDevice(connecting: MutableState) { ModalManager.start.showModalCloseable { close -> val invitation = rememberSaveable { mutableStateOf(null) } + val port = rememberSaveable { mutableStateOf(null) } val pairing = remember { chatModel.newRemoteHostPairing } val sessionCode = when (val state = pairing.value?.second) { is RemoteHostSessionState.PendingConfirmation -> state.sessionCode @@ -247,7 +250,8 @@ private fun showAddingMobileDevice(connecting: MutableState) { title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection), invitation = invitation.value, deviceName = remoteDeviceName, - sessionCode = cachedSessionCode + sessionCode = cachedSessionCode, + port = port.value ) val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) } LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { @@ -266,6 +270,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { if (r != null) { connecting.value = true invitation.value = r.second + port.value = r.third } } onDispose { @@ -284,6 +289,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState ModalManager.start.showModalCloseable { close -> val pairing = remember { chatModel.newRemoteHostPairing } val invitation = rememberSaveable { mutableStateOf(null) } + val port = rememberSaveable { mutableStateOf(null) } val sessionCode = when (val state = pairing.value?.second) { is RemoteHostSessionState.PendingConfirmation -> state.sessionCode else -> null @@ -298,6 +304,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState invitation = invitation.value, deviceName = pairing.value?.first?.hostDeviceName ?: rh.hostDeviceName, sessionCode = cachedSessionCode, + port = port.value ) var remoteHostId by rememberSaveable { mutableStateOf(null) } LaunchedEffect(Unit) { @@ -307,6 +314,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState connecting.value = true remoteHostId = rh_?.remoteHostId invitation.value = inv + port.value = r.third } } LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { @@ -343,7 +351,8 @@ private fun showConnectedMobileDevice(rh: RemoteHostInfo, disconnectHost: () -> title = stringResource(MR.strings.connected_to_mobile), invitation = null, deviceName = rh.hostDeviceName, - sessionCode = sessionCode + sessionCode = sessionCode, + port = null, ) Spacer(Modifier.height(DEFAULT_PADDING_HALF)) SectionItemView(disconnectHost) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 4f29d8cb34..622ba4abb5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1662,7 +1662,8 @@ %s]]> Disconnect desktop? Only one device can work at the same time - Use from desktop in mobile app and scan QR code]]> + Use from desktop in mobile app and scan QR code.]]> + %s]]> Bad desktop address Incompatible version Desktop app version %s is not compatible with this app. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 093ca05646..8aa847c78b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1465,7 +1465,7 @@ Nuovo dispositivo mobile Indirizzo desktop Solo un dispositivo può funzionare nello stesso momento - Usa dal desktop nell\'app mobile e scansiona il codice QR]]> + Usa dal desktop nell\'app mobile e scansiona il codice QR.]]> Versione incompatibile (nuovo)]]> Scollegare il desktop? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index c112844395..801df9d609 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1463,7 +1463,7 @@ Nieuw mobiel apparaat Desktop adres Er kan slechts één apparaat tegelijkertijd werken - Gebruik vanaf desktop in de mobiele app en scan de QR-code]]> + Gebruik vanaf desktop in de mobiele app en scan de QR-code.]]> Incompatibele versie (nieuw)]]> Desktop ontkoppelen? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index e672c189a4..90f475135f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1465,7 +1465,7 @@ 新移动设备 桌面地址 同一时刻只有一台设备可以工作 - 从桌面使用并扫描二维码]]> + 从桌面使用并扫描二维码.]]> 不兼容的版本 (新)]]> 取消链接桌面端? From 15fdab597bb89d6721689dcd3b1ba25f3645fd69 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 23 Nov 2023 01:36:52 +0400 Subject: [PATCH 256/294] core: shuffle members when sending messages and introductions; send to admins and owners first (#3431) * core: shuffle members when sending messages and introductions; send to admins and owners first * refactor --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b67fa1f3dd..2696b5311b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -39,19 +39,20 @@ import Data.Either (fromRight, rights) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortOn) +import Data.List (find, foldl', isSuffixOf, partition, sortBy, sortOn) import Data.List.NonEmpty (NonEmpty, nonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) +import Data.Ord (comparing) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) import Data.Time.Clock.System (SystemTime, systemToUTCTime) -import Data.Word (Word32) +import Data.Word (Word16, Word32) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Chat.Call @@ -3552,7 +3553,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do members <- withStore' $ \db -> getGroupMembers db user gInfo intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m - forM_ intros $ \intro -> + shuffledIntros <- liftIO $ shuffleMembers intros $ \GroupMemberIntro {reMember = GroupMember {memberRole}} -> memberRole + forM_ shuffledIntros $ \intro -> processIntro intro `catchChatError` (toView . CRChatError (Just user)) where sendXGrpLinkMem = do @@ -5525,7 +5527,8 @@ sendGroupMessage' :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> [Group sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) -- TODO collect failed deliveries into a single error - rs <- forM (filter memberCurrent members) $ \m -> + recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) $ \GroupMember {memberRole} -> memberRole + rs <- forM recipientMembers $ \m -> messageMember m msg `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) let sentToMembers = catMaybes rs pure (msg, sentToMembers) @@ -5563,6 +5566,15 @@ sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do XGrpMsgForward {} -> True _ -> False +shuffleMembers :: [a] -> (a -> GroupMemberRole) -> IO [a] +shuffleMembers ms role = do + let (adminMs, otherMs) = partition ((GRAdmin <=) . role) ms + liftM2 (<>) (shuffle adminMs) (shuffle otherMs) + where + random :: IO Word16 + random = randomRIO (0, 65535) + shuffle xs = map snd . sortBy (comparing fst) <$> mapM (\x -> (,x) <$> random) xs + sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn = do pendingMessages <- withStore' $ \db -> getPendingGroupMessages db groupMemberId From 4af4fbae2be547591060b65b5cd722718cc744d8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:10:41 +0000 Subject: [PATCH 257/294] ios: close sheet when disconnecting from desktop (#3435) --- apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift | 2 +- .../common/src/commonMain/resources/MR/base/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 0f6ef7be0d..0ee2312889 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -382,7 +382,7 @@ struct ConnectDesktopView: View { private func disconnectButton() -> some View { Button { - disconnectDesktop() + disconnectDesktop(.dismiss) } label: { Label("Disconnect", systemImage: "multiply") } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 622ba4abb5..44f950c325 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1659,7 +1659,7 @@ Unlink desktop? Unlink Disconnect - %s]]> + %s was disconnected]]> Disconnect desktop? Only one device can work at the same time Use from desktop in mobile app and scan QR code.]]> From 2d4e99d6101285a639aeb9579fceac91e6fd0b0d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:11:32 +0000 Subject: [PATCH 258/294] cli: set device name for remote control via CLI option (#3427) * cli: set device name for remote control via CLI option * fix * add property in tests --- apps/simplex-broadcast-bot/src/Broadcast/Options.hs | 1 + .../src/Directory/Options.hs | 1 + src/Simplex/Chat.hs | 7 ++++--- src/Simplex/Chat/Controller.hs | 3 ++- src/Simplex/Chat/Mobile.hs | 4 +++- src/Simplex/Chat/Options.hs | 11 +++++++++++ src/Simplex/Chat/Terminal.hs | 3 ++- tests/ChatClient.hs | 1 + 8 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index 76b349a499..3758af2fcb 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -74,6 +74,7 @@ mkChatOpts :: BroadcastBotOpts -> ChatOpts mkChatOpts BroadcastBotOpts {coreOptions} = ChatOpts { coreOptions, + deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatServerPort = Nothing, diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 1f06afe116..8f28c9013a 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -72,6 +72,7 @@ mkChatOpts :: DirectoryOpts -> ChatOpts mkChatOpts DirectoryOpts {coreOptions} = ChatOpts { coreOptions, + deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatServerPort = Nothing, diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2696b5311b..bbbb4a9249 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -153,7 +153,8 @@ defaultChatConfig = cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes coreApi = False, - highlyAvailable = False + highlyAvailable = False, + deviceNameForRemote = "" } _defaultSMPServers :: NonEmpty SMPServerWithAuth @@ -197,7 +198,7 @@ createChatDatabase filePrefix key confirmMigrations = runExceptT $ do pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do +newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir, deviceNameForRemote} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, deviceName, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable} firstTime = dbNew chatStore @@ -215,7 +216,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty - localDeviceName <- newTVarIO "" -- TODO set in config + localDeviceName <- newTVarIO $ fromMaybe deviceNameForRemote deviceName multicastSubscribers <- newTMVarIO 0 remoteSessionSeq <- newTVarIO 0 remoteHostSessions <- atomically TM.empty diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e470a6fd89..aa23587456 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -135,7 +135,8 @@ data ChatConfig = ChatConfig cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64, -- microseconds coreApi :: Bool, - highlyAvailable :: Bool + highlyAvailable :: Bool, + deviceNameForRemote :: Text } data DefaultAgentServers = DefaultAgentServers diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index ffcf5a0cea..9127102543 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -181,6 +181,7 @@ mobileChatOpts dbFilePrefix dbKey = tbqSize = 1024, highlyAvailable = False }, + deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatServerPort = Nothing, @@ -197,7 +198,8 @@ defaultMobileConfig = defaultChatConfig { confirmMigrations = MCYesUp, logLevel = CLLError, - coreApi = True + coreApi = True, + deviceNameForRemote = "Mobile" } getActiveUser_ :: SQLiteStore -> IO (Maybe User) diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 04aef29dfa..7ce6305d20 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -19,6 +19,7 @@ where import Control.Logger.Simple (LogLevel (..)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B +import Data.Text (Text) import Numeric.Natural (Natural) import Options.Applicative import Simplex.Chat.Controller (ChatLogLevel (..), updateStr, versionNumber, versionString) @@ -32,6 +33,7 @@ import System.FilePath (combine) data ChatOpts = ChatOpts { coreOptions :: CoreChatOpts, + deviceName :: Maybe Text, chatCmd :: String, chatCmdDelay :: Int, chatServerPort :: Maybe String, @@ -200,6 +202,14 @@ coreChatOptsP appDir defaultDbFileName = do chatOptsP :: FilePath -> FilePath -> Parser ChatOpts chatOptsP appDir defaultDbFileName = do coreOptions <- coreChatOptsP appDir defaultDbFileName + deviceName <- + optional $ + strOption + ( long "device-name" + <> short 'e' + <> metavar "DEVICE" + <> help "Device name to use in connections with remote hosts and controller" + ) chatCmd <- strOption ( long "execute" @@ -268,6 +278,7 @@ chatOptsP appDir defaultDbFileName = do pure ChatOpts { coreOptions, + deviceName, chatCmd, chatCmdDelay, chatServerPort, diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 68aaa51318..89d234f944 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -35,7 +35,8 @@ terminalChatConfig = ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"], xftp = defaultXFTPServers, netCfg = defaultNetworkConfig - } + }, + deviceNameForRemote = "SimpleX CLI" } simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c9f7c9dc87..b75fa38419 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -74,6 +74,7 @@ testOpts = tbqSize = 16, highlyAvailable = False }, + deviceName = Nothing, chatCmd = "", chatCmdDelay = 3, chatServerPort = Nothing, From 01f351e65a735f3081171b1d9baf29849802b551 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:12:42 +0000 Subject: [PATCH 259/294] ios: haskell RTS options (#3433) --- apps/ios/Shared/SimpleXApp.swift | 3 ++- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 +++++++ apps/ios/SimpleXChat/SimpleX.h | 4 +++- apps/ios/SimpleXChat/hs_init.c | 25 ++++++++++++++++++++++ apps/ios/SimpleXChat/hs_init.h | 14 ++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 apps/ios/SimpleXChat/hs_init.c create mode 100644 apps/ios/SimpleXChat/hs_init.h diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index f030f105bf..fd1ec9511b 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -27,7 +27,8 @@ struct SimpleXApp: App { init() { DispatchQueue.global(qos: .background).sync { - hs_init(0, nil) + haskell_init() +// hs_init(0, nil) } UserDefaults.standard.register(defaults: appDefaults) setGroupDefaults() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d2456b6966..3326dc0601 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -118,6 +118,8 @@ 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; + 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -399,6 +401,8 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; + 5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = ""; }; + 5CD67B8E2B0E858A00C510B1 /* hs_init.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hs_init.c; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -808,6 +812,8 @@ 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, 5CE2BA96284537A800EC33A6 /* dummy.m */, + 5CD67B8D2B0E858A00C510B1 /* hs_init.h */, + 5CD67B8E2B0E858A00C510B1 /* hs_init.c */, ); path = SimpleXChat; sourceTree = ""; @@ -892,6 +898,7 @@ buildActionMask = 2147483647; files = ( 5CE2BA77284530BF00EC33A6 /* SimpleXChat.h in Headers */, + 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */, 5CE2BA952845354B00EC33A6 /* SimpleX.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1262,6 +1269,7 @@ 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */, 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, + 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */, 5CE2BA91284533A300EC33A6 /* Notifications.swift in Sources */, 5CE2BA79284530CC00EC33A6 /* SimpleXChat.docc in Sources */, 5CE2BA90284533A300EC33A6 /* JSON.swift in Sources */, diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 250f1cb73f..2872922a9b 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -9,7 +9,7 @@ #ifndef SimpleX_h #define SimpleX_h -#endif /* SimpleX_h */ +#include "hs_init.h" extern void hs_init(int argc, char **argv[]); @@ -42,3 +42,5 @@ extern char *chat_encrypt_file(char *fromPath, char *toPath); // chat_decrypt_file returns null-terminated string with the error message extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); + +#endif /* SimpleX_h */ diff --git a/apps/ios/SimpleXChat/hs_init.c b/apps/ios/SimpleXChat/hs_init.c new file mode 100644 index 0000000000..7a5ea24560 --- /dev/null +++ b/apps/ios/SimpleXChat/hs_init.c @@ -0,0 +1,25 @@ +// +// hs_init.c +// SimpleXChat +// +// Created by Evgeny on 22/11/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +#include "hs_init.h" + +extern void hs_init_with_rtsopts(int * argc, char **argv[]); + +void haskell_init(void) { + int argc = 5; + char *argv[] = { + "simplex", + "+RTS", // requires `hs_init_with_rtsopts` + "-A16m", // chunk size for new allocations + "-H64m", // initial heap size + "-xn", // non-moving GC + 0 + }; + char **pargv = argv; + hs_init_with_rtsopts(&argc, &pargv); +} diff --git a/apps/ios/SimpleXChat/hs_init.h b/apps/ios/SimpleXChat/hs_init.h new file mode 100644 index 0000000000..48850e819c --- /dev/null +++ b/apps/ios/SimpleXChat/hs_init.h @@ -0,0 +1,14 @@ +// +// hs_init.h +// SimpleXChat +// +// Created by Evgeny on 22/11/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +#ifndef hs_init_h +#define hs_init_h + +void haskell_init(void); + +#endif /* hs_init_h */ From d0419df3964e61b47190a31044cbb07634fe7de0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:34:30 +0000 Subject: [PATCH 260/294] android: close Use from desktop when disconnecting --- .../simplex/common/views/remote/ConnectDesktopView.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index d631836dd6..8d3cfddcec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -53,6 +53,7 @@ fun ConnectDesktopView(close: () -> Unit) { ModalView(close = closeWithAlert) { ConnectDesktopLayout( deviceName = deviceName.value!!, + close ) } val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } @@ -67,7 +68,7 @@ fun ConnectDesktopView(close: () -> Unit) { } @Composable -private fun ConnectDesktopLayout(deviceName: String) { +private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) { val sessionAddress = remember { mutableStateOf("") } val remoteCtrls = remember { mutableStateListOf() } val session = remember { chatModel.remoteCtrlSession }.value @@ -89,7 +90,7 @@ private fun ConnectDesktopLayout(deviceName: String) { } } - is UIRemoteCtrlSessionState.Connected -> ActiveSession(session, session.sessionState.remoteCtrl) + is UIRemoteCtrlSessionState.Connected -> ActiveSession(session, session.sessionState.remoteCtrl, close) } } else { ConnectDesktop(deviceName, remoteCtrls, sessionAddress) @@ -205,7 +206,7 @@ private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { } @Composable -private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo) { +private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close: () -> Unit) { AppBarTitle(stringResource(MR.strings.connected_to_desktop)) SectionView(stringResource(MR.strings.connected_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(rc.deviceViewName) @@ -223,7 +224,7 @@ private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo) { SectionSpacer() SectionView { - DisconnectButton(::disconnectDesktop) + DisconnectButton { disconnectDesktop(close) } } } From 954b7150af057a770657b67c2da646527ed9e426 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 23 Nov 2023 00:37:33 +0200 Subject: [PATCH 261/294] android, desktop: set RTS options for core (#3418) * desktop: allow settings RTS options * set initial heap and arena sizes * add non-moving GC for even more productivity/less reallocs * add RTS options for android too --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../src/commonMain/cpp/android/simplex-api.c | 14 ++++++++++++-- .../src/commonMain/cpp/desktop/simplex-api.c | 7 +++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index b729e3b7f5..54478425f1 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -5,7 +5,7 @@ //#include // from the RTS -void hs_init(int * argc, char **argv[]); +void hs_init_with_rtsopts(int * argc, char **argv[]); // from android-support void setLineBuffering(void); @@ -32,7 +32,17 @@ Java_chat_simplex_common_platform_CoreKt_pipeStdOutToSocket(JNIEnv *env, __unuse JNIEXPORT void JNICALL Java_chat_simplex_common_platform_CoreKt_initHS(__unused JNIEnv *env, __unused jclass clazz) { - hs_init(NULL, NULL); + int argc = 5; + char *argv[] = { + "simplex", + "+RTS", // requires `hs_init_with_rtsopts` + "-A16m", // chunk size for new allocations + "-H64m", // initial heap size + "-xn", // non-moving GC + NULL + }; + char **pargv = argv; + hs_init_with_rtsopts(&argc, &pargv); setLineBuffering(); } diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 1b0d11a2b7..e2cd7ed55c 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -4,11 +4,14 @@ #include // from the RTS -void hs_init(int * argc, char **argv[]); +void hs_init_with_rtsopts(int * argc, char **argv[]); JNIEXPORT void JNICALL Java_chat_simplex_common_platform_CoreKt_initHS(JNIEnv *env, jclass clazz) { - hs_init(NULL, NULL); + int argc = 5; + char *argv[] = {"simplex", "+RTS", "-A16m", "-H64m", "-xn", NULL}; // see android/simplex-api.c for details + char **pargv = argv; + hs_init_with_rtsopts(&argc, &pargv); } // from simplex-chat From 1b7baa244a3d2a541bad4e225cc3dd16b215ae7d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 08:39:08 +0000 Subject: [PATCH 262/294] core: track network statuses in CLI (#3434) --- src/Simplex/Chat.hs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bbbb4a9249..2ad92b4ef9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2855,17 +2855,17 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do let connIds = map aConnId' pcs pure (connIds, M.fromList $ zip connIds pcs) contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> Bool -> m () - contactSubsToView rs cts ce = ifM (asks $ coreApi . config) notifyAPI notifyCLI + contactSubsToView rs cts ce = do + chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) + ifM (asks $ coreApi . config) (notifyAPI statuses) notifyCLI where notifyCLI = do let cRs = resultsFor rs cts cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors - notifyAPI = do - let statuses = M.foldrWithKey' addStatus [] cts - chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) - toView $ CRNetworkStatuses (Just user) $ map (uncurry ConnNetworkStatus) statuses + notifyAPI = toView . CRNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus) + statuses = M.foldrWithKey' addStatus [] cts where addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] addStatus _ Contact {activeConn = Nothing} nss = nss @@ -3086,12 +3086,12 @@ processAgentMessageNoConn = \case where hostEvent :: ChatResponse -> m () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv conns nsStatus event = ifM (asks $ coreApi . config) notifyAPI notifyCLI + serverEvent srv conns nsStatus event = do + chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds + ifM (asks $ coreApi . config) (notifyAPI connIds) notifyCLI where - notifyAPI = do - let connIds = map AgentConnId conns - chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds - toView $ CRNetworkStatus nsStatus connIds + connIds = map AgentConnId conns + notifyAPI = toView . CRNetworkStatus nsStatus notifyCLI = do cs <- withStore' (`getConnectionsContacts` conns) toView $ event srv cs From d3f9616f9ba869f1ce62a12f230baa9433e2426c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 10:07:26 +0000 Subject: [PATCH 263/294] core: report controller info when found via multicast (#3437) * core: report controller info when found via multicast * handle parse error --- src/Simplex/Chat/Controller.hs | 4 ++-- src/Simplex/Chat/Remote.hs | 21 +++++++++++++-------- src/Simplex/Chat/View.hs | 32 ++++++++++++++++++-------------- tests/RemoteTests.hs | 3 +-- 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index aa23587456..d4f9c93317 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -665,7 +665,7 @@ data ChatResponse | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} - | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect + | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo, ctrlAppInfo_ :: Maybe CtrlAppInfo, appVersion :: AppVersion, compatible :: Bool} | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} @@ -703,7 +703,7 @@ allowRemoteEvent = \case CRRemoteHostStopped _ -> False CRRemoteFileStored {} -> False CRRemoteCtrlList _ -> False - CRRemoteCtrlFound _ -> False + CRRemoteCtrlFound {} -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlSessionCode {} -> False CRRemoteCtrlConnected _ -> False diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d8d53aa0c4..29eeec3c52 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -397,12 +397,15 @@ findKnownRemoteCtrl = do cmdOk <- newEmptyTMVarIO action <- async $ handleCtrlError sseq "findKnownRemoteCtrl.discover" $ do atomically $ takeTMVar cmdOk - (RCCtrlPairing {ctrlFingerprint}, inv) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings + (RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <- + timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings + ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchChatError` const (pure Nothing) rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" Just rc -> pure rc atomically $ putTMVar foundCtrl (rc, inv) - toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching)} + let compatible = isJust $ compatibleAppVersion hostAppVersionRange . appVersionRange =<< ctrlAppInfo_ + toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible} updateRemoteCtrlSession sseq $ \case RCSessionStarting -> Right RCSessionSearching {action, foundCtrl} _ -> Left $ ChatErrorRemoteCtrl RCEBadState @@ -439,7 +442,8 @@ startRemoteCtrlSession = do connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> SessionSeq -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) sseq = handleCtrlError sseq "connectRemoteCtrl" $ do - (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app + ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName} <- parseCtrlAppInfo app + v <- checkAppVersion ctrlInfo rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca mapM_ (validateRemoteCtrl inv) rc_ hostAppInfo <- getHostAppInfo v @@ -467,18 +471,19 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) in Right RCSessionPendingConfirmation {remoteCtrlId_, ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} _ -> Left $ ChatErrorRemoteCtrl RCEBadState toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` Just RCSPendingConfirmation {sessionCode}) <$> rc_, sessionCode} - parseCtrlAppInfo ctrlAppInfo = do - ctrlInfo@CtrlAppInfo {appVersionRange} <- - liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo - v <- case compatibleAppVersion hostAppVersionRange appVersionRange of + checkAppVersion CtrlAppInfo {appVersionRange} = + case compatibleAppVersion hostAppVersionRange appVersionRange of Just (AppCompatible v) -> pure v Nothing -> throwError $ ChatErrorRemoteCtrl $ RCEBadVersion $ maxVersion appVersionRange - pure (ctrlInfo, v) getHostAppInfo appVersion = do hostDeviceName <- chatReadVar localDeviceName encryptFiles <- chatReadVar encryptLocalFiles pure HostAppInfo {appVersion, deviceName = hostDeviceName, encoding = localEncoding, encryptFiles} +parseCtrlAppInfo :: ChatMonad m => JT.Value -> m CtrlAppInfo +parseCtrlAppInfo ctrlAppInfo = do + liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo + handleRemoteCommand :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> RemoteCrypto -> TBQueue ChatResponse -> HTTP2Request -> m () handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 07664b9539..1867ee2f61 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -306,18 +306,16 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs - CRRemoteCtrlFound rc -> - ["remote controller found:", viewRemoteCtrl rc] - CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo = CtrlAppInfo {deviceName, appVersionRange = AppVersionRange _ (AppVersion ctrlVersion)}, appVersion = AppVersion v} -> - [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") - <> (if T.null deviceName then "" else plain deviceName <> ", ") - <> ("v" <> plain (V.showVersion ctrlVersion) <> ctrlVersionInfo) + CRRemoteCtrlFound {remoteCtrl = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName}, ctrlAppInfo_, appVersion, compatible} -> + [ "remote controller " <> sShow remoteCtrlId <> " found: " + <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ ] where - ctrlVersionInfo - | ctrlVersion < v = " (older than this app - upgrade controller)" - | ctrlVersion > v = " (newer than this app - upgrade it)" - | otherwise = "" + deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " + CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> + [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") + <> viewRemoteCtrl ctrlAppInfo appVersion True + ] CRRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, "Compare session code with controller and use:", @@ -1734,10 +1732,16 @@ viewRemoteCtrls = \case RCSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" RCSConnected _ -> " (connected)" --- TODO fingerprint, accepted? -viewRemoteCtrl :: RemoteCtrlInfo -> StyledString -viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName} = - plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName +viewRemoteCtrl :: CtrlAppInfo -> AppVersion -> Bool -> StyledString +viewRemoteCtrl CtrlAppInfo {deviceName, appVersionRange = AppVersionRange _ (AppVersion ctrlVersion)} (AppVersion v) compatible = + (if T.null deviceName then "" else plain deviceName <> ", ") + <> ("v" <> plain (V.showVersion ctrlVersion) <> ctrlVersionInfo) + where + ctrlVersionInfo + | ctrlVersion < v = " (older than this app - upgrade controller" <> showCompatible <> ")" + | ctrlVersion > v = " (newer than this app - upgrade it" <> showCompatible <> ")" + | otherwise = "" + showCompatible = if compatible then "" else ", " <> bold' "not compatible" viewChatError :: ChatLogLevel -> Bool -> ChatError -> [StyledString] viewChatError logLevel testView = \case diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 9ede37f9a4..9f77245d94 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -459,8 +459,7 @@ startRemoteDiscover mobile desktop = do _inv <- getTermLine desktop -- will use multicast instead mobile ##> "/find remote ctrl" mobile <## "ok" - mobile <## "remote controller found:" - mobile <## "1. My desktop" + mobile <## ("remote controller 1 found: My desktop, v" <> versionNumber) mobile ##> "/confirm remote ctrl 1" mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) From d837f87f09359b7db40fef65bd79a521efc385f3 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 23 Nov 2023 13:00:57 +0200 Subject: [PATCH 264/294] fix circular cancel at rcDiscoverCtrl (#3438) --- src/Simplex/Chat/Remote.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 29eeec3c52..ff271ba1c5 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -659,7 +659,8 @@ cancelActiveRemoteCtrl sseq_ = handleAny (logError . tshow) $ do cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () cancelRemoteCtrl handlingError = \case RCSessionStarting -> pure () - RCSessionSearching {action} -> uninterruptibleCancel action + RCSessionSearching {action} -> + unless handlingError $ uninterruptibleCancel action RCSessionConnecting {rcsClient, rcsWaitSession} -> do unless handlingError $ uninterruptibleCancel rcsWaitSession cancelCtrlClient rcsClient From 4d3529a3e0295361563da048476dee7d81a71d36 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 17:00:13 +0000 Subject: [PATCH 265/294] desktop: fix incorrect remote host for active user (#3441) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 3 +++ .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 7541b7f34f..6739cdee70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -638,6 +638,9 @@ data class User( val addressShared: Boolean = profile.contactLink != null + fun updateRemoteHostId(rh: Long?): User = + if (rh == null) this else this.copy(remoteHostId = rh) + companion object { val sampleData = User( remoteHostId = null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index bfcec69da8..8f837e1c5a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -468,7 +468,7 @@ object ChatController { suspend fun apiGetActiveUser(rh: Long?): User? { val r = sendCmd(rh, CC.ShowActiveUser()) - if (r is CR.ActiveUser) return r.user + if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") chatModel.userCreated.value = false return null @@ -476,7 +476,7 @@ object ChatController { suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? { val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp)) - if (r is CR.ActiveUser) return r.user + if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) else if ( r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName || r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.UserExists @@ -501,7 +501,7 @@ object ChatController { suspend fun apiSetActiveUser(rh: Long?, userId: Long, viewPwd: String?): User { val r = sendCmd(rh, CC.ApiSetActiveUser(userId, viewPwd)) - if (r is CR.ActiveUser) return if (rh == null) r.user else r.user.copy(remoteHostId = rh) + if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}") throw Exception("failed to set the user as active ${r.responseType} ${r.details}") } @@ -538,7 +538,7 @@ object ChatController { private suspend fun setUserPrivacy(rh: Long?, cmd: CC): User { val r = sendCmd(rh, cmd) - if (r is CR.UserPrivacy) return if (rh == null) r.updatedUser else r.updatedUser.copy(remoteHostId = rh) + if (r is CR.UserPrivacy) return r.updatedUser.updateRemoteHostId(rh) else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}") } From f7903c5c83329d6bd1a8d28e082cc3583acff414 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 24 Nov 2023 03:49:53 +0800 Subject: [PATCH 266/294] desktop: close remote host connecting screen on stop of host (#3440) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 2 +- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 4 ++-- .../simplex/common/views/remote/ConnectMobileView.kt | 10 ++++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 6739cdee70..18eba71fb9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -113,7 +113,7 @@ object ChatModel { val currentRemoteHost = mutableStateOf(null) val remoteHostId: Long? @Composable get() = remember { currentRemoteHost }.value?.remoteHostId fun remoteHostId(): Long? = currentRemoteHost.value?.remoteHostId - val newRemoteHostPairing = mutableStateOf?>(null) + val remoteHostPairing = mutableStateOf?>(null) val remoteCtrlSession = mutableStateOf(null) fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 8f837e1c5a..5a706efe69 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1836,7 +1836,7 @@ object ChatController { is CR.GroupMemberRatchetSync -> chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) is CR.RemoteHostSessionCode -> { - chatModel.newRemoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) + chatModel.remoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) } is CR.RemoteHostConnected -> { // TODO needs to update it instead in sessions @@ -1845,7 +1845,7 @@ object ChatController { } is CR.RemoteHostStopped -> { val disconnectedHost = chatModel.remoteHosts.firstOrNull { it.remoteHostId == r.remoteHostId_ } - chatModel.newRemoteHostPairing.value = null + chatModel.remoteHostPairing.value = null if (disconnectedHost != null) { showToast( generalGetString(MR.strings.remote_host_was_disconnected_toast).format(disconnectedHost.hostDeviceName.ifEmpty { disconnectedHost.remoteHostId.toString() }) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 163d15ce62..a22cbfd9bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -235,7 +235,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { ModalManager.start.showModalCloseable { close -> val invitation = rememberSaveable { mutableStateOf(null) } val port = rememberSaveable { mutableStateOf(null) } - val pairing = remember { chatModel.newRemoteHostPairing } + val pairing = remember { chatModel.remoteHostPairing } val sessionCode = when (val state = pairing.value?.second) { is RemoteHostSessionState.PendingConfirmation -> state.sessionCode else -> null @@ -271,6 +271,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { connecting.value = true invitation.value = r.second port.value = r.third + chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting } } onDispose { @@ -279,7 +280,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { chatController.stopRemoteHost(null) } } - chatModel.newRemoteHostPairing.value = null + chatModel.remoteHostPairing.value = null } } } @@ -287,7 +288,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { ModalManager.start.showModalCloseable { close -> - val pairing = remember { chatModel.newRemoteHostPairing } + val pairing = remember { chatModel.remoteHostPairing } val invitation = rememberSaveable { mutableStateOf(null) } val port = rememberSaveable { mutableStateOf(null) } val sessionCode = when (val state = pairing.value?.second) { @@ -315,6 +316,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState remoteHostId = rh_?.remoteHostId invitation.value = inv port.value = r.third + chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting } } LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { @@ -334,7 +336,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState chatController.stopRemoteHost(remoteHostId) } } - chatModel.newRemoteHostPairing.value = null + chatModel.remoteHostPairing.value = null } } } From b2dbb558f9a242f94ad5a7963ffebb34f0b3ea11 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 24 Nov 2023 05:00:11 +0800 Subject: [PATCH 267/294] android, desktop: connect remote desktop via multicast (#3442) * android, desktop: connect remote desktop via multicast * changes * string --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/ChatModel.kt | 5 +- .../chat/simplex/common/model/SimpleXAPI.kt | 48 ++++- .../views/helpers/DefaultBasicTextField.kt | 2 +- .../common/views/remote/ConnectDesktopView.kt | 166 +++++++++++++++--- .../common/views/remote/ConnectMobileView.kt | 8 +- .../commonMain/resources/MR/base/strings.xml | 7 +- 6 files changed, 200 insertions(+), 36 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 18eba71fb9..144f461a97 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2921,7 +2921,7 @@ enum class NotificationPreviewMode { } data class RemoteCtrlSession( - val ctrlAppInfo: CtrlAppInfo, + val ctrlAppInfo: CtrlAppInfo?, val appVersion: String, val sessionState: UIRemoteCtrlSessionState ) { @@ -2939,6 +2939,7 @@ data class RemoteCtrlSession( @Serializable sealed class RemoteCtrlSessionState { @Serializable @SerialName("starting") object Starting: RemoteCtrlSessionState() + @Serializable @SerialName("searching") object Searching: RemoteCtrlSessionState() @Serializable @SerialName("connecting") object Connecting: RemoteCtrlSessionState() @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val sessionCode: String): RemoteCtrlSessionState() @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState() @@ -2946,6 +2947,8 @@ sealed class RemoteCtrlSessionState { sealed class UIRemoteCtrlSessionState { @Serializable @SerialName("starting") object Starting: UIRemoteCtrlSessionState() + @Serializable @SerialName("searching") object Searching: UIRemoteCtrlSessionState() + @Serializable @SerialName("found") data class Found(val remoteCtrl: RemoteCtrlInfo, val compatible: Boolean): UIRemoteCtrlSessionState() @Serializable @SerialName("connecting") data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() @Serializable @SerialName("connected") data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 5a706efe69..20fa4abf58 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -170,6 +170,7 @@ class AppPreferences { val confirmRemoteSessions = mkBoolPreference(SHARED_PREFS_CONFIRM_REMOTE_SESSIONS, false) val connectRemoteViaMulticast = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST, false) + val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true) val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) private fun mkIntPreference(prefName: String, default: Int) = @@ -314,6 +315,7 @@ class AppPreferences { private const val SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS = "DeviceNameForRemoteAccess" private const val SHARED_PREFS_CONFIRM_REMOTE_SESSIONS = "ConfirmRemoteSessions" private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast" + private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" } } @@ -1432,14 +1434,25 @@ object ChatController { suspend fun connectRemoteCtrl(desktopAddress: String): Pair { val r = sendCmd(null, CC.ConnectRemoteCtrl(desktopAddress)) - if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null - else if (r is CR.ChatCmdError) return null to r - else throw Exception("connectRemoteCtrl error: ${r.responseType} ${r.details}") + return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null + else if (r is CR.ChatCmdError) null to r + else { + apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + null to null + } } suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.FindKnownRemoteCtrl()) - suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.ConfirmRemoteCtrl(rcId)) + suspend fun confirmRemoteCtrl(rcId: Long): Pair { + val r = sendCmd(null, CC.ConfirmRemoteCtrl(remoteCtrlId = rcId)) + return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null + else if (r is CR.ChatCmdError) null to r + else { + apiErrorAlert("confirmRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) + null to null + } + } suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { val r = sendCmd(null, CC.VerifyRemoteCtrlSession(sessionCode)) @@ -1857,8 +1870,15 @@ object ChatController { } } is CR.RemoteCtrlFound -> { - // TODO multicast - Log.d(TAG, "RemoteCtrlFound: ${r.remoteCtrl}") + val sess = chatModel.remoteCtrlSession.value + if (sess != null && sess.sessionState is UIRemoteCtrlSessionState.Searching) { + val state = UIRemoteCtrlSessionState.Found(remoteCtrl = r.remoteCtrl, compatible = r.compatible) + chatModel.remoteCtrlSession.value = RemoteCtrlSession( + ctrlAppInfo = r.ctrlAppInfo_, + appVersion = r.appVersion, + sessionState = state + ) + } } is CR.RemoteCtrlSessionCode -> { val state = UIRemoteCtrlSessionState.PendingConfirmation(remoteCtrl_ = r.remoteCtrl_, sessionCode = r.sessionCode) @@ -1870,7 +1890,13 @@ object ChatController { chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) } is CR.RemoteCtrlStopped -> { - switchToLocalSession() + val sess = chatModel.remoteCtrlSession.value + if (sess != null) { + chatModel.remoteCtrlSession.value = null + if (sess.sessionState is UIRemoteCtrlSessionState.Connected) { + switchToLocalSession() + } + } } else -> Log.d(TAG , "unsupported event: ${r.responseType}") @@ -3782,7 +3808,7 @@ sealed class CR { @Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR() // remote events (mobile) @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() - @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo): CR() + @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo, val ctrlAppInfo_: CtrlAppInfo?, val appVersion: String, val compatible: Boolean): CR() @Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): CR() @Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR() @Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR() @@ -4080,7 +4106,11 @@ sealed class CR { is RemoteHostStopped -> "remote host ID: $remoteHostId_" is RemoteFileStored -> "remote host ID: $remoteHostId\nremoteFileSource:\n" + json.encodeToString(remoteFileSource) is RemoteCtrlList -> json.encodeToString(remoteCtrls) - is RemoteCtrlFound -> json.encodeToString(remoteCtrl) + is RemoteCtrlFound -> "remote ctrl: " + json.encodeToString(remoteCtrl) + + "\nctrlAppInfo: " + + (if (ctrlAppInfo_ == null) "null" else json.encodeToString(ctrlAppInfo_)) + + "\nappVersion: $appVersion" + + "\ncompatible: $compatible" is RemoteCtrlConnecting -> "remote ctrl: " + (if (remoteCtrl_ == null) "null" else json.encodeToString(remoteCtrl_)) + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt index 71801e7a5f..08dfaa0dfb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt @@ -125,7 +125,7 @@ fun DefaultConfigurableTextField( keyboardType: KeyboardType = KeyboardType.Text, dependsOn: State? = null, ) { - var valid by remember { mutableStateOf(validKey(state.value.text)) } + var valid by remember { mutableStateOf(isValid(state.value.text)) } var showKey by remember { mutableStateOf(false) } val icon = if (valid) { if (showKey) painterResource(MR.images.ic_visibility_off_filled) else painterResource(MR.images.ic_visibility_filled) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 8d3cfddcec..44e6969b8d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -37,6 +38,7 @@ import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -69,15 +71,21 @@ fun ConnectDesktopView(close: () -> Unit) { @Composable private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) { + val showConnectScreen = remember { mutableStateOf(true) } val sessionAddress = remember { mutableStateOf("") } val remoteCtrls = remember { mutableStateListOf() } val session = remember { chatModel.remoteCtrlSession }.value Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), ) { - if (session != null) { + val discovery = if (session == null) null else session.sessionState is UIRemoteCtrlSessionState.Searching + if (discovery == true || (discovery == null && !showConnectScreen.value)) { + SearchingDesktop(deviceName, remoteCtrls) + } else if (session != null) { when (session.sessionState) { is UIRemoteCtrlSessionState.Starting -> ConnectingDesktop(session, null) + is UIRemoteCtrlSessionState.Searching -> SearchingDesktop(deviceName, remoteCtrls) + is UIRemoteCtrlSessionState.Found -> FoundDesktop(session, session.sessionState.remoteCtrl, session.sessionState.compatible, remember { controller.appPrefs.connectRemoteViaMulticastAuto.state }, deviceName, remoteCtrls, sessionAddress) is UIRemoteCtrlSessionState.Connecting -> ConnectingDesktop(session, session.sessionState.remoteCtrl_) is UIRemoteCtrlSessionState.PendingConfirmation -> { if (controller.appPrefs.confirmRemoteSessions.get() || session.sessionState.remoteCtrl_ == null) { @@ -97,11 +105,21 @@ private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) { } SectionBottomSpacer() } - DisposableEffect(Unit) { + LaunchedEffect(Unit) { setDeviceName(deviceName) updateRemoteCtrls(remoteCtrls) + val useMulticast = useMulticast(remoteCtrls) + showConnectScreen.value = !useMulticast + if (chatModel.remoteCtrlSession.value != null) { + disconnectDesktop() + } else if (useMulticast) { + findKnownDesktop(showConnectScreen) + } + } + DisposableEffect(Unit) { onDispose { if (chatModel.remoteCtrlSession.value != null) { + showConnectScreen.value = false disconnectDesktop() } } @@ -146,7 +164,75 @@ private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { SectionSpacer() SectionView { - DisconnectButton(::disconnectDesktop) + DisconnectButton(onClick = ::disconnectDesktop) + } +} + +@Composable +private fun SearchingDesktop(deviceName: String, remoteCtrls: SnapshotStateList) { + AppBarTitle(stringResource(MR.strings.connecting_to_desktop)) + SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + DevicesView(deviceName, remoteCtrls) { + if (it != "") { + setDeviceName(it) + controller.appPrefs.deviceNameForRemoteAccess.set(it) + } + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.found_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text(stringResource(MR.strings.waiting_for_desktop), fontStyle = FontStyle.Italic) + } + SectionSpacer() + DisconnectButton(stringResource(MR.strings.scan_QR_code).replace('\n', ' '), MR.images.ic_qr_code, ::disconnectDesktop) +} + +@Composable +private fun FoundDesktop( + session: RemoteCtrlSession, + rc: RemoteCtrlInfo, + compatible: Boolean, + connectRemoteViaMulticastAuto: State, + deviceName: String, + remoteCtrls: SnapshotStateList, + sessionAddress: MutableState, +) { + AppBarTitle(stringResource(MR.strings.found_desktop)) + SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + DevicesView(deviceName, remoteCtrls) { + if (it != "") { + setDeviceName(it) + controller.appPrefs.deviceNameForRemoteAccess.set(it) + } + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.found_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + CtrlDeviceVersionText(session) + if (!compatible) { + Text(stringResource(MR.strings.not_compatible), color = MaterialTheme.colors.error) + } + } + + SectionSpacer() + + if (compatible) { + SectionItemView({ confirmKnownDesktop(sessionAddress, rc) }) { + Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.connect_button), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.connect_button)) + } + } + + if (!compatible || !connectRemoteViaMulticastAuto.value) { + DisconnectButton(stringResource(MR.strings.cancel_verb), onClick = ::disconnectDesktop) + } + + if (compatible && connectRemoteViaMulticastAuto.value) { + LaunchedEffect(Unit) { + confirmKnownDesktop(sessionAddress, rc) + } } } @@ -174,7 +260,7 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC } SectionView { - DisconnectButton(::disconnectDesktop) + DisconnectButton(onClick = ::disconnectDesktop) } } @@ -182,7 +268,7 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC private fun CtrlDeviceNameText(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { val newDesktop = annotatedStringResource(MR.strings.new_desktop) val text = remember(rc) { - var t = AnnotatedString(rc?.deviceViewName ?: session.ctrlAppInfo.deviceName) + var t = AnnotatedString(rc?.deviceViewName ?: session.ctrlAppInfo?.deviceName ?: "") if (rc == null) { t = t + AnnotatedString(" ") + newDesktop } @@ -195,7 +281,7 @@ private fun CtrlDeviceNameText(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { val thisDeviceVersion = annotatedStringResource(MR.strings.this_device_version, session.appVersion) val text = remember(session) { - val v = AnnotatedString(session.ctrlAppInfo.appVersionRange.maxVersion) + val v = AnnotatedString(session.ctrlAppInfo?.appVersionRange?.maxVersion ?: "") var t = AnnotatedString("v$v") if (v.text != session.appVersion) { t = t + AnnotatedString(" ") + thisDeviceVersion @@ -243,7 +329,8 @@ private fun SessionCodeText(code: String) { private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList, updateDeviceName: (String) -> Unit) { DeviceNameField(deviceName) { updateDeviceName(it) } if (remoteCtrls.isNotEmpty()) { - SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) } }) { + SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) } + }) { Text(generalGetString(MR.strings.linked_desktops)) } } @@ -336,8 +423,13 @@ private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { PreferenceToggle(stringResource(MR.strings.verify_connections), remember { controller.appPrefs.confirmRemoteSessions.state }.value) { controller.appPrefs.confirmRemoteSessions.set(it) } - PreferenceToggle(stringResource(MR.strings.discover_on_network), remember { controller.appPrefs.connectRemoteViaMulticast.state }.value && false) { - controller.appPrefs.confirmRemoteSessions.set(it) + PreferenceToggle(stringResource(MR.strings.discover_on_network), remember { controller.appPrefs.connectRemoteViaMulticast.state }.value) { + controller.appPrefs.connectRemoteViaMulticast.set(it) + } + if (remember { controller.appPrefs.connectRemoteViaMulticast.state }.value) { + PreferenceToggle(stringResource(MR.strings.multicast_connect_automatically), remember { controller.appPrefs.connectRemoteViaMulticastAuto.state }.value) { + controller.appPrefs.connectRemoteViaMulticastAuto.set(it) + } } } SectionBottomSpacer() @@ -355,13 +447,11 @@ private fun setDeviceName(name: String) { } } -private fun updateRemoteCtrls(remoteCtrls: SnapshotStateList) { - withBGApi { - val res = controller.listRemoteCtrls() - if (res != null) { - remoteCtrls.clear() - remoteCtrls.addAll(res) - } +private suspend fun updateRemoteCtrls(remoteCtrls: SnapshotStateList) { + val res = controller.listRemoteCtrls() + if (res != null) { + remoteCtrls.clear() + remoteCtrls.addAll(res) } } @@ -369,9 +459,34 @@ private fun processDesktopQRCode(sessionAddress: MutableState, resp: Str connectDesktopAddress(sessionAddress, resp) } -private fun connectDesktopAddress(sessionAddress: MutableState, addr: String) { +private fun findKnownDesktop(showConnectScreen: MutableState) { withBGApi { - val res = controller.connectRemoteCtrl(desktopAddress = addr) + if (controller.findKnownRemoteCtrl()) { + chatModel.remoteCtrlSession.value = RemoteCtrlSession( + ctrlAppInfo = null, + appVersion = "", + sessionState = UIRemoteCtrlSessionState.Searching + ) + showConnectScreen.value = true + } + } +} + +private fun confirmKnownDesktop(sessionAddress: MutableState, rc: RemoteCtrlInfo) { + connectDesktop(sessionAddress) { + controller.confirmRemoteCtrl(rc.remoteCtrlId) + } +} + +private fun connectDesktopAddress(sessionAddress: MutableState, addr: String) { + connectDesktop(sessionAddress) { + controller.connectRemoteCtrl(addr) + } +} + +private fun connectDesktop(sessionAddress: MutableState, connect: suspend () -> Pair) { + withBGApi { + val res = connect() if (res.first != null) { val (rc_, ctrlAppInfo, v) = res.first!! sessionAddress.value = "" @@ -409,18 +524,25 @@ private fun verifyDesktopSessionCode(remoteCtrls: SnapshotStateList Unit) { +private fun DisconnectButton(label: String = generalGetString(MR.strings.disconnect_remote_host), icon: ImageResource = MR.images.ic_close, onClick: () -> Unit) { SectionItemView(onClick) { - Icon(painterResource(MR.images.ic_close), generalGetString(MR.strings.disconnect_remote_host), tint = MaterialTheme.colors.secondary) + Icon(painterResource(icon), label, tint = MaterialTheme.colors.secondary) TextIconSpaced(false) - Text(generalGetString(MR.strings.disconnect_remote_host)) + Text(label) } } +private fun useMulticast(remoteCtrls: List): Boolean = + controller.appPrefs.connectRemoteViaMulticast.get() && remoteCtrls.isNotEmpty() + private fun disconnectDesktop(close: (() -> Unit)? = null) { withBGApi { controller.stopRemoteCtrl() - switchToLocalSession() + if (chatModel.remoteCtrlSession.value?.sessionState is UIRemoteCtrlSessionState.Connected) { + switchToLocalSession() + } else { + chatModel.remoteCtrlSession.value = null + } close?.invoke() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index a22cbfd9bb..53f0339bee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -30,6 +30,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @@ -90,6 +91,9 @@ fun ConnectMobileLayout( SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) + PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), remember { controller.appPrefs.offerRemoteMulticast.state }.value) { + controller.appPrefs.offerRemoteMulticast.set(it) + } SectionDividerSpaced(maxBottomPadding = false) } SectionView(stringResource(MR.strings.devices).uppercase()) { @@ -266,7 +270,7 @@ private fun showAddingMobileDevice(connecting: MutableState) { } DisposableEffect(Unit) { withBGApi { - val r = chatModel.controller.startRemoteHost(null) + val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get()) if (r != null) { connecting.value = true invitation.value = r.second @@ -309,7 +313,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState ) var remoteHostId by rememberSaveable { mutableStateOf(null) } LaunchedEffect(Unit) { - val r = chatModel.controller.startRemoteHost(rh.remoteHostId) + val r = chatModel.controller.startRemoteHost(rh.remoteHostId, controller.appPrefs.offerRemoteMulticast.get()) if (r != null) { val (rh_, inv) = r connecting.value = true diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 44f950c325..22f5d2fbec 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1670,6 +1670,8 @@ Connection terminated Session code Connecting to desktop + Waiting for desktop… + Found desktop Connect to desktop Connected to desktop Connected desktop @@ -1681,9 +1683,12 @@ Scan QR code from desktop Desktop address Verify connections - Discover on network + Discover via local network + Discoverable via local network + Connect automatically Paste desktop address Desktop + Not compatible! Coming soon! From 8f0a9cd6090920c89667557e082c1449c8a2ca0d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 21:22:29 +0000 Subject: [PATCH 268/294] ios: connect remote desktop via multicast (#3436) * ios: connect remote desktop via multicast * works * fix camera freeze when leaving linked devices view * label * fix linked devices * fix compatible * string --- apps/ios/Shared/Model/ChatModel.swift | 8 +- apps/ios/Shared/Model/SimpleXAPI.swift | 29 +++- .../RemoteAccess/ConnectDesktopView.swift | 152 ++++++++++++++++-- .../Views/UserSettings/SettingsView.swift | 6 +- apps/ios/SimpleXChat/APITypes.swift | 5 +- .../chat/simplex/common/model/SimpleXAPI.kt | 2 +- 6 files changed, 173 insertions(+), 29 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 90e4272b0d..4c0f361024 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -770,7 +770,7 @@ final class GMember: ObservableObject, Identifiable { } struct RemoteCtrlSession { - var ctrlAppInfo: CtrlAppInfo + var ctrlAppInfo: CtrlAppInfo? var appVersion: String var sessionState: UIRemoteCtrlSessionState @@ -782,6 +782,10 @@ struct RemoteCtrlSession { if case .connected = sessionState { true } else { false } } + var discovery: Bool { + if case .searching = sessionState { true } else { false } + } + var sessionCode: String? { switch sessionState { case let .pendingConfirmation(_, sessionCode): sessionCode @@ -793,6 +797,8 @@ struct RemoteCtrlSession { enum UIRemoteCtrlSessionState { case starting + case searching + case found(remoteCtrl: RemoteCtrlInfo, compatible: Bool) case connecting(remoteCtrl_: RemoteCtrlInfo?) case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 9e4cc7cd02..e010de3e8f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -919,8 +919,10 @@ func findKnownRemoteCtrl() async throws { try await sendCommandOkResp(.findKnownRemoteCtrl) } -func confirmRemoteCtrl(_ rcId: Int64) async throws { - try await sendCommandOkResp(.confirmRemoteCtrl(remoteCtrlId: rcId)) +func confirmRemoteCtrl(_ rcId: Int64) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { + let r = await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId)) + if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } + throw r } func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo { @@ -1714,9 +1716,17 @@ func processReceivedMsg(_ res: ChatResponse) async { await MainActor.run { m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats) } - case let .remoteCtrlFound(remoteCtrl): - // TODO multicast - logger.debug("\(String(describing: remoteCtrl))") + case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): + await MainActor.run { + if let sess = m.remoteCtrlSession, case .searching = sess.sessionState { + let state = UIRemoteCtrlSessionState.found(remoteCtrl: remoteCtrl, compatible: compatible) + m.remoteCtrlSession = RemoteCtrlSession( + ctrlAppInfo: ctrlAppInfo_, + appVersion: appVersion, + sessionState: state + ) + } + } case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): await MainActor.run { let state = UIRemoteCtrlSessionState.pendingConfirmation(remoteCtrl_: remoteCtrl_, sessionCode: sessionCode) @@ -1731,8 +1741,13 @@ func processReceivedMsg(_ res: ChatResponse) async { case .remoteCtrlStopped: // This delay is needed to cancel the session that fails on network failure, // e.g. when user did not grant permission to access local network yet. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - switchToLocalSession() + if let sess = m.remoteCtrlSession { + m.remoteCtrlSession = nil + if case .connected = sess.sessionState { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + switchToLocalSession() + } + } } default: logger.debug("unsupported event: \(res.responseType)") diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 0ee2312889..1f120860e2 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -16,12 +16,19 @@ struct ConnectDesktopView: View { var viaSettings = false @AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name @AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false - @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = false - @AppStorage(DEFAULT_OFFER_REMOTE_MULTICAST) private var offerRemoteMulticast = true + @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = true + @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO) private var connectRemoteViaMulticastAuto = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var sessionAddress: String = "" @State private var remoteCtrls: [RemoteCtrlInfo] = [] @State private var alert: ConnectDesktopAlert? + @State private var showConnectScreen = true + @State private var showQRCodeScanner = true + @State private var firstAppearance = true + + private var useMulticast: Bool { + connectRemoteViaMulticast && !remoteCtrls.isEmpty + } private enum ConnectDesktopAlert: Identifiable { case unlinkDesktop(rc: RemoteCtrlInfo) @@ -67,9 +74,14 @@ struct ConnectDesktopView: View { var viewBody: some View { Group { - if let session = m.remoteCtrlSession { + let discovery = m.remoteCtrlSession?.discovery + if discovery == true || (discovery == nil && !showConnectScreen) { + searchingDesktopView() + } else if let session = m.remoteCtrlSession { switch session.sessionState { case .starting: connectingDesktopView(session, nil) + case .searching: searchingDesktopView() + case let .found(rc, compatible): foundDesktopView(session, rc, compatible) case let .connecting(rc_): connectingDesktopView(session, rc_) case let .pendingConfirmation(rc_, sessCode): if confirmRemoteSessions || rc_ == nil { @@ -81,16 +93,35 @@ struct ConnectDesktopView: View { } case let .connected(rc, _): activeSessionView(session, rc) } - } else { + // The hack below prevents camera freezing when exiting linked devices view. + // Using showQRCodeScanner inside connectDesktopView or passing it as parameter still results in freezing. + } else if showQRCodeScanner || firstAppearance { connectDesktopView() + } else { + connectDesktopView(showScanner: false) } } .onAppear { setDeviceName(deviceName) updateRemoteCtrls() + showConnectScreen = !useMulticast + if m.remoteCtrlSession != nil { + disconnectDesktop() + } else if useMulticast { + findKnownDesktop() + } + // The hack below prevents camera freezing when exiting linked devices view. + // `firstAppearance` prevents camera flicker when the view first opens. + // moving `showQRCodeScanner = false` to `onDisappear` (to avoid `firstAppearance`) does not prevent freeze. + showQRCodeScanner = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + firstAppearance = false + showQRCodeScanner = true + } } .onDisappear { if m.remoteCtrlSession != nil { + showConnectScreen = false disconnectDesktop() } } @@ -134,12 +165,14 @@ struct ConnectDesktopView: View { .interactiveDismissDisabled(m.activeRemoteCtrl) } - private func connectDesktopView() -> some View { + private func connectDesktopView(showScanner: Bool = true) -> some View { List { Section("This device name") { devicesView() } - scanDesctopAddressView() + if showScanner { + scanDesctopAddressView() + } if developerTools { desktopAddressView() } @@ -167,6 +200,56 @@ struct ConnectDesktopView: View { .navigationTitle("Connecting to desktop") } + private func searchingDesktopView() -> some View { + List { + Section("This device name") { + devicesView() + } + Section("Found desktop") { + Text("Waiting for desktop...").italic() + Button { + disconnectDesktop(.dismiss) + } label: { + Label("Scan QR code", systemImage: "qrcode") + } + } + } + .navigationTitle("Connecting to desktop") + } + + @ViewBuilder private func foundDesktopView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo, _ compatible: Bool) -> some View { + let v = List { + Section("This device name") { + devicesView() + } + Section("Found desktop") { + ctrlDeviceNameText(session, rc) + ctrlDeviceVersionText(session) + if !compatible { + Text("Not compatible!").foregroundColor(.red) + } else if !connectRemoteViaMulticastAuto { + Button { + confirmKnownDesktop(rc) + } label: { + Label("Connect", systemImage: "checkmark") + } + } + } + if !compatible && !connectRemoteViaMulticastAuto { + Section { + disconnectButton("Cancel") + } + } + } + .navigationTitle("Found desktop") + + if compatible && connectRemoteViaMulticastAuto { + v.onAppear { confirmKnownDesktop(rc) } + } else { + v + } + } + private func verifySessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?, _ sessCode: String) -> some View { List { Section("Connected to desktop") { @@ -191,7 +274,7 @@ struct ConnectDesktopView: View { } private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text { - var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo.deviceName) + var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo?.deviceName ?? "") if (rc == nil) { t = t + Text(" ") + Text("(new)").italic() } @@ -199,8 +282,8 @@ struct ConnectDesktopView: View { } private func ctrlDeviceVersionText(_ session: RemoteCtrlSession) -> Text { - let v = session.ctrlAppInfo.appVersionRange.maxVersion - var t = Text("v\(v)") + let v = session.ctrlAppInfo?.appVersionRange.maxVersion + var t = Text("v\(v ?? "")") if v != session.appVersion { t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic() } @@ -301,7 +384,10 @@ struct ConnectDesktopView: View { Section("Linked desktop options") { Toggle("Verify connections", isOn: $confirmRemoteSessions) - Toggle("Discover on network", isOn: $connectRemoteViaMulticast).disabled(true) + Toggle("Discover via local network", isOn: $connectRemoteViaMulticast) + if connectRemoteViaMulticast { + Toggle("Connect automatically", isOn: $connectRemoteViaMulticastAuto) + } } } .navigationTitle("Linked desktops") @@ -335,10 +421,42 @@ struct ConnectDesktopView: View { } } - private func connectDesktopAddress(_ addr: String) { + private func findKnownDesktop() { Task { do { - let (rc_, ctrlAppInfo, v) = try await connectRemoteCtrl(desktopAddress: addr) + try await findKnownRemoteCtrl() + await MainActor.run { + m.remoteCtrlSession = RemoteCtrlSession( + ctrlAppInfo: nil, + appVersion: "", + sessionState: .searching + ) + showConnectScreen = true + } + } catch let e { + await MainActor.run { + errorAlert(e) + } + } + } + } + + private func confirmKnownDesktop(_ rc: RemoteCtrlInfo) { + connectDesktop_ { + try await confirmRemoteCtrl(rc.remoteCtrlId) + } + } + + private func connectDesktopAddress(_ addr: String) { + connectDesktop_ { + try await connectRemoteCtrl(desktopAddress: addr) + } + } + + private func connectDesktop_(_ connect: @escaping () async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String)) { + Task { + do { + let (rc_, ctrlAppInfo, v) = try await connect() await MainActor.run { sessionAddress = "" m.remoteCtrlSession = RemoteCtrlSession( @@ -380,11 +498,11 @@ struct ConnectDesktopView: View { } } - private func disconnectButton() -> some View { + private func disconnectButton(_ label: LocalizedStringKey = "Disconnect") -> some View { Button { disconnectDesktop(.dismiss) } label: { - Label("Disconnect", systemImage: "multiply") + Label(label, systemImage: "multiply") } } @@ -393,7 +511,11 @@ struct ConnectDesktopView: View { do { try await stopRemoteCtrl() await MainActor.run { - switchToLocalSession() + if case .connected = m.remoteCtrlSession?.sessionState { + switchToLocalSession() + } else { + m.remoteCtrlSession = nil + } switch action { case .back: dismiss() case .dismiss: dismiss() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 423786eb66..f889d9c394 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -56,7 +56,7 @@ let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites" let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" -let DEFAULT_OFFER_REMOTE_MULTICAST = "offerRemoteMulticast" +let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -91,8 +91,8 @@ let appDefaults: [String: Any] = [ DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, DEFAULT_SHOW_UNREAD_AND_FAVORITES: false, DEFAULT_CONFIRM_REMOTE_SESSIONS: false, - DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: false, - DEFAULT_OFFER_REMOTE_MULTICAST: true + DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true, + DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true, ] enum SimpleXLinkMode: String, Identifiable { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index e7409a072b..ad0e5ee100 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -609,7 +609,7 @@ public enum ChatResponse: Decodable, Error { case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) // remote desktop responses/events case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) - case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo) + case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool) case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) @@ -903,7 +903,7 @@ public enum ChatResponse: Decodable, Error { case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) - case let .remoteCtrlFound(remoteCtrl): return String(describing: remoteCtrl) + case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)" case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) @@ -1546,6 +1546,7 @@ public struct RemoteCtrlInfo: Decodable { public enum RemoteCtrlSessionState: Decodable { case starting + case searching case connecting case pendingConfirmation(sessionCode: String) case connected(sessionCode: String) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 20fa4abf58..56b4b0a139 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1394,7 +1394,7 @@ object ChatController { chatModel.remoteHosts.addAll(hosts) } - suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Triple? { + suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true): Triple? { val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast)) if (r is CR.RemoteHostStarted) return Triple(r.remoteHost_, r.invitation, r.ctrlPort) apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r) From 6f3174d0a10f4174bb2e44fdc39c184578343c00 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 23 Nov 2023 21:25:32 +0000 Subject: [PATCH 269/294] android, desktop: remove unnecessary serialization --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 144f461a97..754e9dfe26 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2946,10 +2946,10 @@ sealed class RemoteCtrlSessionState { } sealed class UIRemoteCtrlSessionState { - @Serializable @SerialName("starting") object Starting: UIRemoteCtrlSessionState() - @Serializable @SerialName("searching") object Searching: UIRemoteCtrlSessionState() - @Serializable @SerialName("found") data class Found(val remoteCtrl: RemoteCtrlInfo, val compatible: Boolean): UIRemoteCtrlSessionState() - @Serializable @SerialName("connecting") data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() - @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() - @Serializable @SerialName("connected") data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() + object Starting: UIRemoteCtrlSessionState() + object Searching: UIRemoteCtrlSessionState() + data class Found(val remoteCtrl: RemoteCtrlInfo, val compatible: Boolean): UIRemoteCtrlSessionState() + data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() + data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() + data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() } From 74e80eb34837bbb4d1c118e5362ef0f2637a1050 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 24 Nov 2023 00:00:20 +0200 Subject: [PATCH 270/294] core: add remote stop reason and state (#3444) * add remote stop reason and state * rename --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat/Controller.hs | 25 +++++++++++++++--- src/Simplex/Chat/Remote.hs | 46 ++++++++++++++++++---------------- src/Simplex/Chat/View.hs | 8 +++--- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d4f9c93317..d40810167e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -662,14 +662,14 @@ data ChatResponse | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} - | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId} + | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId, rhsState :: RemoteHostSessionState, rhStopReason :: RemoteHostStopReason} | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo, ctrlAppInfo_ :: Maybe CtrlAppInfo, appVersion :: AppVersion, compatible :: Bool} | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} - | CRRemoteCtrlStopped + | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -700,14 +700,14 @@ allowRemoteEvent = \case CRRemoteHostSessionCode {} -> False CRNewRemoteHost _ -> False CRRemoteHostConnected _ -> False - CRRemoteHostStopped _ -> False + CRRemoteHostStopped {} -> False CRRemoteFileStored {} -> False CRRemoteCtrlList _ -> False CRRemoteCtrlFound {} -> False CRRemoteCtrlConnecting {} -> False CRRemoteCtrlSessionCode {} -> False CRRemoteCtrlConnected _ -> False - CRRemoteCtrlStopped -> False + CRRemoteCtrlStopped {} -> False CRSQLResult _ -> False CRSlowSQLQueries {} -> False _ -> True @@ -1083,6 +1083,12 @@ data RemoteHostError | RHEProtocolError RemoteProtocolError deriving (Show, Exception) +data RemoteHostStopReason + = RHSRConnectionFailed ChatError + | RHSRCrashed ChatError + | RHSRDisconnected + deriving (Show, Exception) + -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteCtrlError = RCEInactive -- ^ No session is running @@ -1098,6 +1104,13 @@ data RemoteCtrlError | RCEProtocolError {protocolError :: RemoteProtocolError} deriving (Show, Exception) +data RemoteCtrlStopReason + = RCSRDiscoveryFailed ChatError + | RCSRConnectionFailed ChatError + | RCSRSetupFailed ChatError + | RCSRDisconnected + deriving (Show, Exception) + data ArchiveError = AEImport {chatError :: ChatError} | AEImportFile {file :: String, chatError :: ChatError} @@ -1323,6 +1336,10 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) $(JQ.deriveJSON defaultJSON ''RemoteCtrlInfo) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index ff271ba1c5..e2137b35a2 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -169,12 +169,12 @@ startRemoteHost rh_ = do handleConnectError :: ChatMonad m => RHKey -> SessionSeq -> m a -> m a handleConnectError rhKey sessSeq action = action `catchChatError` \err -> do logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err - cancelRemoteHostSession (Just sessSeq) rhKey + cancelRemoteHostSession (Just (sessSeq, RHSRConnectionFailed err)) rhKey throwError err handleHostError :: ChatMonad m => SessionSeq -> TVar RHKey -> m () -> m () handleHostError sessSeq rhKeyVar action = action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err - readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just sessSeq) + readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err)) waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars = do (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars @@ -220,7 +220,7 @@ startRemoteHost rh_ = do onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m () onDisconnected rhKey sseq = do logDebug $ "HTTP2 client disconnected: " <> tshow (rhKey, sseq) - cancelRemoteHostSession (Just sseq) rhKey + cancelRemoteHostSession (Just (sseq, RHSRDisconnected)) rhKey pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () pollEvents rhId rhClient = do oq <- asks outputQ @@ -246,24 +246,25 @@ closeRemoteHost rhKey = do logNote $ "Closing remote host session for " <> tshow rhKey cancelRemoteHostSession Nothing rhKey -cancelRemoteHostSession :: ChatMonad m => Maybe SessionSeq -> RHKey -> m () -cancelRemoteHostSession sseq_ rhKey = do +cancelRemoteHostSession :: ChatMonad m => Maybe (SessionSeq, RemoteHostStopReason) -> RHKey -> m () +cancelRemoteHostSession handlerInfo_ rhKey = do sessions <- asks remoteHostSessions crh <- asks currentRemoteHost deregistered <- atomically $ TM.lookup rhKey sessions >>= \case Nothing -> pure Nothing - Just (sessSeq, _) | maybe False (/= sessSeq) sseq_ -> pure Nothing -- ignore cancel from a ghost session handler + Just (sessSeq, _) | maybe False (/= sessSeq) (fst <$> handlerInfo_) -> pure Nothing -- ignore cancel from a ghost session handler Just (_, rhs) -> do TM.delete rhKey sessions modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH pure $ Just rhs forM_ deregistered $ \session -> do liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) - when handlingError $ toView $ CRRemoteHostStopped rhId_ + forM_ (snd <$> handlerInfo_) $ \rhStopReason -> + toView $ CRRemoteHostStopped {remoteHostId_, rhsState = rhsSessionState session, rhStopReason} where - handlingError = isJust sseq_ - rhId_ = case rhKey of + handlingError = isJust handlerInfo_ + remoteHostId_ = case rhKey of RHNew -> Nothing RHId rhId -> Just rhId @@ -395,7 +396,7 @@ findKnownRemoteCtrl = do sseq <- startRemoteCtrlSession foundCtrl <- newEmptyTMVarIO cmdOk <- newEmptyTMVarIO - action <- async $ handleCtrlError sseq "findKnownRemoteCtrl.discover" $ do + action <- async $ handleCtrlError sseq RCSRDiscoveryFailed "findKnownRemoteCtrl.discover" $ do atomically $ takeTMVar cmdOk (RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings @@ -441,7 +442,7 @@ startRemoteCtrlSession = do Right sseq <$ writeTVar session (Just (sseq, RCSessionStarting)) connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> SessionSeq -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) -connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) sseq = handleCtrlError sseq "connectRemoteCtrl" $ do +connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) sseq = handleCtrlError sseq RCSRConnectionFailed "connectRemoteCtrl" $ do ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName} <- parseCtrlAppInfo app v <- checkAppVersion ctrlInfo rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca @@ -452,7 +453,7 @@ connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) cmdOk <- newEmptyTMVarIO rcsWaitSession <- async $ do atomically $ takeTMVar cmdOk - handleCtrlError sseq "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars + handleCtrlError sseq RCSRConnectionFailed "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars updateRemoteCtrlSession sseq $ \case RCSessionStarting -> Right RCSessionConnecting {remoteCtrlId_ = remoteCtrlId' <$> rc_, rcsClient, rcsWaitSession} _ -> Left $ ChatErrorRemoteCtrl RCEBadState @@ -602,7 +603,7 @@ verifyRemoteCtrlSession execChatCommand sessCode' = do Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive Just (sseq, RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation}) -> pure (sseq, rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) _ -> throwError $ ChatErrorRemoteCtrl RCEBadState - handleCtrlError sseq "verifyRemoteCtrlSession" $ do + handleCtrlError sseq RCSRSetupFailed "verifyRemoteCtrlSession" $ do let verified = sameVerificationCode sessCode' sessionCode timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . liftIO $ confirmCtrlSession client verified -- signal verification result before crashing unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode @@ -630,31 +631,32 @@ verifyRemoteCtrlSession execChatCommand sessCode' = do monitor sseq server = do res <- waitCatch server logInfo $ "HTTP2 server stopped: " <> tshow res - cancelActiveRemoteCtrl (Just sseq) + cancelActiveRemoteCtrl $ Just (sseq, RCSRDisconnected) stopRemoteCtrl :: ChatMonad m => m () stopRemoteCtrl = cancelActiveRemoteCtrl Nothing -handleCtrlError :: ChatMonad m => SessionSeq -> Text -> m a -> m a -handleCtrlError sseq name action = +handleCtrlError :: ChatMonad m => SessionSeq -> (ChatError -> RemoteCtrlStopReason) -> Text -> m a -> m a +handleCtrlError sseq mkReason name action = action `catchChatError` \e -> do logError $ name <> " remote ctrl error: " <> tshow e - cancelActiveRemoteCtrl (Just sseq) + cancelActiveRemoteCtrl $ Just (sseq, mkReason e) throwError e -- | Stop session controller, unless session update key is present but stale -cancelActiveRemoteCtrl :: ChatMonad m => Maybe SessionSeq -> m () -cancelActiveRemoteCtrl sseq_ = handleAny (logError . tshow) $ do +cancelActiveRemoteCtrl :: ChatMonad m => Maybe (SessionSeq, RemoteCtrlStopReason) -> m () +cancelActiveRemoteCtrl handlerInfo_ = handleAny (logError . tshow) $ do var <- asks remoteCtrlSession session_ <- atomically $ readTVar var >>= \case Nothing -> pure Nothing - Just (oldSeq, _) | maybe False (/= oldSeq) sseq_ -> pure Nothing + Just (oldSeq, _) | maybe False (/= oldSeq) (fst <$> handlerInfo_) -> pure Nothing Just (_, s) -> Just s <$ writeTVar var Nothing forM_ session_ $ \session -> do liftIO $ cancelRemoteCtrl handlingError session - when handlingError $ toView CRRemoteCtrlStopped + forM_ (snd <$> handlerInfo_) $ \rcStopReason -> + toView CRRemoteCtrlStopped {rcsState = rcsSessionState session, rcStopReason} where - handlingError = isJust sseq_ + handlingError = isJust handlerInfo_ cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () cancelRemoteCtrl handlingError = \case diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1867ee2f61..9cd26650fb 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -299,8 +299,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe ] CRNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] - CRRemoteHostStopped rhId_ -> - [ maybe "new remote host" (mappend "remote host " . sShow) rhId_ <> " stopped" + CRRemoteHostStopped {remoteHostId_} -> + [ maybe "new remote host" (mappend "remote host " . sShow) remoteHostId_ <> " stopped" ] CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] @@ -311,7 +311,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ ] where - deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " + deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") <> viewRemoteCtrl ctrlAppInfo appVersion True @@ -323,7 +323,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe ] CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] - CRRemoteCtrlStopped -> ["remote controller stopped"] + CRRemoteCtrlStopped {} -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = From b1cf1656a0ad2b2775ea0613cac3bed555d2a4bf Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 24 Nov 2023 10:48:14 +0000 Subject: [PATCH 271/294] core: cli remote control help section (#3445) --- src/Simplex/Chat.hs | 4 ++-- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Help.hs | 31 ++++++++++++++++++++++++++++++- src/Simplex/Chat/View.hs | 2 ++ tests/RemoteTests.hs | 1 + 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2ad92b4ef9..cf3767b5c9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -6073,8 +6073,9 @@ chatCommandP = ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups, ("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts, ("/help address" <|> "/ha") $> ChatHelp HSMyAddress, - "/help incognito" $> ChatHelp HSIncognito, + ("/help incognito" <|> "/hi") $> ChatHelp HSIncognito, ("/help messages" <|> "/hm") $> ChatHelp HSMessages, + ("/help remote" <|> "/hr") $> ChatHelp HSRemote, ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, ("/help" <|> "/h") $> ChatHelp HSMain, @@ -6181,7 +6182,6 @@ chatCommandP = "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), - -- "/create remote host" $> CreateRemoteHost, "/list remote hosts" $> ListRemoteHosts, "/switch remote host " *> (SwitchRemoteHost <$> ("local" $> Nothing <|> (Just <$> A.decimal))), "/start remote host " *> (StartRemoteHost <$> ("new" $> Nothing <|> (Just <$> ((,) <$> A.decimal <*> (" multicast=" *> onOffP <|> pure False))))), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d40810167e..3c0054ec1b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -203,7 +203,7 @@ data ChatController = ChatController contactMergeEnabled :: TVar Bool } -data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase +data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase deriving (Show) data ChatCommand diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index c2a5720633..5d0548ca3f 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -10,6 +10,7 @@ module Simplex.Chat.Help myAddressHelpInfo, incognitoHelpInfo, messagesHelpInfo, + remoteHelpInfo, markdownInfo, settingsInfo, databaseHelpInfo, @@ -87,7 +88,7 @@ chatHelpInfo = green "Create your address: " <> highlight "/address", "", green "Other commands:", - indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "settings", "db"], + indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db"], indent <> highlight "/profile " <> " - show / update user profile", indent <> highlight "/delete " <> " - delete contact and all messages with them", indent <> highlight "/chats " <> " - most recent chats", @@ -272,6 +273,34 @@ messagesHelpInfo = indent <> highlight "! #team (hi) " <> " - to edit your message in the group #team" ] +remoteHelpInfo :: [StyledString] +remoteHelpInfo = + map + styleMarkdown + [ green "Remote control", + "You can use CLI as a remote controller for a mobile app or as a remote host for a desktop app (or another CLI).", + "For example, you can run CLI on a server and use it from a desktop computer, connecting via SSH port forwarding.", + "", + indent <> highlight "/set device name " <> " - set CLI name for remote connections", + "", + green "Using as remote controller", + indent <> highlight "/start remote host new " <> " - pair and connect a new remote host", + indent <> highlight "/start remote host [multicast=on] " <> " - start connection with a known (paired) remote host", + indent <> highlight "/stop remote host new " <> " - cancel pairing with a new remote host", + indent <> highlight "/stop remote host " <> " - stop connection with connected remote host", + indent <> highlight "/switch remote host local " <> " - switch to using local database", + indent <> highlight "/switch remote host " <> " - switch to connected remote host", + indent <> highlight "/list remote hosts " <> " - list known remote hosts", + indent <> highlight "/delete remote host " <> " - delete (unpair) known remote hosts - also deletes all host files from controller", + "", + green "Using as remote host", + indent <> highlight "/connect remote ctrl " <> " - connect to remote controller via the adress from /start remote host", + indent <> highlight "/stop remote ctrl " <> " - stop connection with remote controller", + indent <> highlight "/find remote ctrl " <> " - find known remote controller on the local network (it should be started with multicast=on)", + indent <> highlight "/list remote ctrls " <> " - list known remote controllers", + indent <> highlight "/delete remote ctrl " <> " - delete known remote controller" + ] + markdownInfo :: [StyledString] markdownInfo = map diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9cd26650fb..4dbda6da69 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -136,6 +136,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe HSIncognito -> incognitoHelpInfo HSMessages -> messagesHelpInfo HSMarkdown -> markdownInfo + HSRemote -> remoteHelpInfo HSSettings -> settingsInfo HSDatabase -> databaseHelpInfo CRWelcome user -> chatWelcome user @@ -310,6 +311,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [ "remote controller " <> sShow remoteCtrlId <> " found: " <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ ] + <> [ "use " <> highlight ("/confirm remote ctrl " <> show remoteCtrlId) <> " to connect" | isJust ctrlAppInfo_ && compatible] where deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 9f77245d94..147f1d9395 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -460,6 +460,7 @@ startRemoteDiscover mobile desktop = do mobile ##> "/find remote ctrl" mobile <## "ok" mobile <## ("remote controller 1 found: My desktop, v" <> versionNumber) + mobile <## "use /confirm remote ctrl 1 to connect" mobile ##> "/confirm remote ctrl 1" mobile <## ("connecting remote controller 1: My desktop, v" <> versionNumber) From c9aec88c3934e77a7dbd85c346ba8b8c925c6fe1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 24 Nov 2023 13:14:52 +0000 Subject: [PATCH 272/294] desktop: fix sending videos via connected mobile --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 56b4b0a139..5b44312cc3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2348,7 +2348,7 @@ sealed class CC { is DeleteRemoteHost -> "/delete remote host $remoteHostId" is StoreRemoteFile -> "/store remote file $remoteHostId " + - (if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)} ") + + (if (storeEncrypted == null) "" else "encrypt=${onOff(storeEncrypted)} ") + localPath is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}" is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation" From bfd13f059a2320b98e80530931627cd5a3545dfb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 24 Nov 2023 14:18:31 +0000 Subject: [PATCH 273/294] ui: translations (#3446) * Translated using Weblate (Dutch) Currently translated at 100.0% (1502 of 1502 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1503 of 1503 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1503 of 1503 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Russian) Currently translated at 97.7% (1459 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (1493 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1502 of 1502 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1503 of 1503 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1503 of 1503 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Russian) Currently translated at 97.7% (1459 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (1493 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Russian) Currently translated at 100.0% (1493 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 99.8% (1339 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Arabic) Currently translated at 92.4% (1380 of 1493 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1494 of 1494 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Added translation using Weblate (Hungarian) * Translated using Weblate (Dutch) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Hungarian) Currently translated at 0.5% (8 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 1.4% (21 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 2.0% (30 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 2.1% (32 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 10.9% (163 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 12.3% (184 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Hungarian) Currently translated at 14.8% (222 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Hungarian) Currently translated at 21.6% (323 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 97.7% (1461 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (French) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (French) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/ * Translated using Weblate (Hungarian) Currently translated at 21.9% (328 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 100.0% (1495 of 1495 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (German) Currently translated at 100.0% (1500 of 1500 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1341 of 1341 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1500 of 1500 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1500 of 1500 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Turkish) Currently translated at 70.0% (1051 of 1500 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * ru: corrections * export/import * ios: export * ru: corrections --------- Co-authored-by: M1K4 Co-authored-by: Float Co-authored-by: Hosted Weblate Co-authored-by: J R Co-authored-by: Random Co-authored-by: jonnysemon Co-authored-by: Istvan Novak Co-authored-by: Eric Co-authored-by: mlanp Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: xe1st --- .../bg.xcloc/Localized Contents/bg.xliff | 24 +- .../cs.xcloc/Localized Contents/cs.xliff | 24 +- .../de.xcloc/Localized Contents/de.xliff | 66 +++- .../en.xcloc/Localized Contents/en.xliff | 31 +- .../es.xcloc/Localized Contents/es.xliff | 24 +- .../fi.xcloc/Localized Contents/fi.xliff | 24 +- .../fr.xcloc/Localized Contents/fr.xliff | 66 +++- .../it.xcloc/Localized Contents/it.xliff | 36 +- .../ja.xcloc/Localized Contents/ja.xliff | 24 +- .../nl.xcloc/Localized Contents/nl.xliff | 36 +- .../pl.xcloc/Localized Contents/pl.xliff | 24 +- .../ru.xcloc/Localized Contents/ru.xliff | 140 ++++++- .../th.xcloc/Localized Contents/th.xliff | 24 +- .../uk.xcloc/Localized Contents/uk.xliff | 24 +- .../Localized Contents/zh-Hans.xliff | 24 +- apps/ios/de.lproj/Localizable.strings | 117 ++++++ .../de.lproj/SimpleX--iOS--InfoPlist.strings | 3 + apps/ios/fr.lproj/Localizable.strings | 117 ++++++ .../fr.lproj/SimpleX--iOS--InfoPlist.strings | 3 + apps/ios/it.lproj/Localizable.strings | 30 +- apps/ios/nl.lproj/Localizable.strings | 30 +- apps/ios/ru.lproj/Localizable.strings | 321 ++++++++++++++++ .../ru.lproj/SimpleX--iOS--InfoPlist.strings | 3 + .../commonMain/resources/MR/ar/strings.xml | 8 + .../commonMain/resources/MR/de/strings.xml | 63 +++ .../commonMain/resources/MR/fr/strings.xml | 58 +++ .../commonMain/resources/MR/hu/strings.xml | 359 ++++++++++++++++++ .../commonMain/resources/MR/it/strings.xml | 14 + .../commonMain/resources/MR/nl/strings.xml | 21 +- .../commonMain/resources/MR/ru/strings.xml | 124 +++++- .../commonMain/resources/MR/tr/strings.xml | 23 ++ .../resources/MR/zh-rCN/strings.xml | 21 +- 32 files changed, 1858 insertions(+), 48 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index b5ae218ab9..7a2afea082 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -1172,6 +1172,10 @@ Свързване server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Свързване инкогнито @@ -1862,8 +1866,8 @@ This cannot be undone! Открийте и се присъединете към групи No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2499,6 +2503,10 @@ This cannot be undone! За конзолата No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Френски интерфейс @@ -3449,6 +3457,10 @@ This is your link for group %@! Няма получени или изпратени файлове No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Известия @@ -5396,6 +5408,10 @@ To connect, please ask your contact to create another connection link and check Гласово съобщение… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Изчаква се получаването на файла @@ -5926,6 +5942,10 @@ SimpleX сървърите не могат да видят вашия профи аудио разговор (не е e2e криптиран) No comment provided by engineer. + + author + member role + bad message ID лошо ID на съобщението diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 1e7b2c1fba..be8b23658b 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1172,6 +1172,10 @@ Připojit server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Spojit se inkognito @@ -1862,8 +1866,8 @@ This cannot be undone! Objevte a připojte skupiny No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2499,6 +2503,10 @@ This cannot be undone! Pro konzoli No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Francouzské rozhraní @@ -3449,6 +3457,10 @@ This is your link for group %@! Žádné přijaté ani odeslané soubory No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Oznámení @@ -5396,6 +5408,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Hlasová zpráva… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Čekání na soubor @@ -5926,6 +5942,10 @@ Servery SimpleX nevidí váš profil. zvukový hovor (nešifrovaný e2e) No comment provided by engineer. + + author + member role + bad message ID špatné ID zprávy diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 4d37da1f3a..29f5dd4e3f 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -299,10 +299,12 @@ (new) + (Neu) No comment provided by engineer. (this device v%@) + (Dieses Gerät hat v%@) No comment provided by engineer. @@ -397,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - Optionale Benachrichtigung von gelöschten Kontakten. +- Profilnamen mit Leerzeichen. +- Und mehr! No comment provided by engineer. @@ -885,6 +890,7 @@ Bad desktop address + Falsche Desktop-Adresse No comment provided by engineer. @@ -899,6 +905,7 @@ Better groups + Bessere Gruppen No comment provided by engineer. @@ -913,6 +920,7 @@ Block group members + Gruppenmitglieder blockieren No comment provided by engineer. @@ -1186,6 +1194,10 @@ Verbinden server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Inkognito verbinden @@ -1193,6 +1205,7 @@ Connect to desktop + Mit dem Desktop verbinden No comment provided by engineer. @@ -1241,10 +1254,12 @@ Das ist Ihr eigener Einmal-Link! Connected desktop + Verbundener Desktop No comment provided by engineer. Connected to desktop + Mit dem Desktop verbunden No comment provided by engineer. @@ -1259,6 +1274,7 @@ Das ist Ihr eigener Einmal-Link! Connecting to desktop + Mit dem Desktop verbinden No comment provided by engineer. @@ -1283,6 +1299,7 @@ Das ist Ihr eigener Einmal-Link! Connection terminated + Verbindung beendet No comment provided by engineer. @@ -1367,6 +1384,7 @@ Das ist Ihr eigener Einmal-Link! Create a group using a random profile. + Erstellen Sie eine Gruppe mit einem zufälligen Profil. No comment provided by engineer. @@ -1781,14 +1799,17 @@ Das kann nicht rückgängig gemacht werden! Desktop address + Desktop-Adresse No comment provided by engineer. Desktop app version %@ is not compatible with this app. + Desktop App-Version %@ ist mit dieser App nicht kompatibel. No comment provided by engineer. Desktop devices + Desktop-Geräte No comment provided by engineer. @@ -1883,6 +1904,7 @@ Das kann nicht rückgängig gemacht werden! Disconnect desktop? + Desktop-Verbindung trennen? No comment provided by engineer. @@ -1890,8 +1912,8 @@ Das kann nicht rückgängig gemacht werden! Gruppen entdecken und ihnen beitreten No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2066,10 +2088,12 @@ Das kann nicht rückgängig gemacht werden! Encryption re-negotiation error + Fehler bei der Neuverhandlung der Verschlüsselung message decrypt error item Encryption re-negotiation failed. + Neuverhandlung der Verschlüsselung fehlgeschlagen. No comment provided by engineer. @@ -2104,6 +2128,7 @@ Das kann nicht rückgängig gemacht werden! Enter this device name… + Geben Sie diesen Gerätenamen ein… No comment provided by engineer. @@ -2433,6 +2458,7 @@ Das kann nicht rückgängig gemacht werden! Faster joining and more reliable messages. + Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung. No comment provided by engineer. @@ -2530,6 +2556,10 @@ Das kann nicht rückgängig gemacht werden! Für Konsole No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Französische Bedienoberfläche @@ -2857,6 +2887,7 @@ Das kann nicht rückgängig gemacht werden! Incognito groups + Inkognito-Gruppen No comment provided by engineer. @@ -2891,6 +2922,7 @@ Das kann nicht rückgängig gemacht werden! Incompatible version + Inkompatible Version No comment provided by engineer. @@ -3065,6 +3097,7 @@ Das ist Ihr Link für die Gruppe %@! Keep the app open to use it from desktop + Die App muss geöffnet bleiben, um sie vom Desktop aus nutzen zu können No comment provided by engineer. @@ -3129,14 +3162,17 @@ Das ist Ihr Link für die Gruppe %@! Link mobile and desktop apps! 🔗 + Verknüpfe Mobiltelefon- und Desktop-Apps! 🔗 No comment provided by engineer. Linked desktop options + Verknüpfte Desktop-Optionen No comment provided by engineer. Linked desktops + Verknüpfte Desktops No comment provided by engineer. @@ -3489,6 +3525,10 @@ Das ist Ihr Link für die Gruppe %@! Keine empfangenen oder gesendeten Dateien No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Benachrichtigungen @@ -3710,6 +3750,7 @@ Das ist Ihr Link für die Gruppe %@! Paste desktop address + Desktop-Adresse einfügen No comment provided by engineer. @@ -4309,6 +4350,7 @@ Das ist Ihr Link für die Gruppe %@! Scan QR code from desktop + Den QR-Code vom Desktop scannen No comment provided by engineer. @@ -4533,6 +4575,7 @@ Das ist Ihr Link für die Gruppe %@! Session code + Sitzungscode No comment provided by engineer. @@ -5034,6 +5077,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This device name + Dieser Gerätename No comment provided by engineer. @@ -5073,6 +5117,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro To hide unwanted messages. + Um unerwünschte Nachrichten zu verbergen. No comment provided by engineer. @@ -5236,10 +5281,12 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Unlink + Entkoppeln No comment provided by engineer. Unlink desktop? + Desktop entkoppeln? No comment provided by engineer. @@ -5334,6 +5381,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Use from desktop + Vom Desktop aus nutzen No comment provided by engineer. @@ -5368,10 +5416,12 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verify code with desktop + Code mit dem Desktop überprüfen No comment provided by engineer. Verify connection + Verbindung überprüfen No comment provided by engineer. @@ -5381,6 +5431,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Verify connections + Verbindungen überprüfen No comment provided by engineer. @@ -5395,6 +5446,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Via secure quantum resistant protocol. + Über ein sicheres quantenbeständiges Protokoll. No comment provided by engineer. @@ -5447,6 +5499,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Sprachnachrichten… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Warte auf Datei @@ -5992,6 +6048,10 @@ SimpleX-Server können Ihr Profil nicht einsehen. Audioanruf (nicht E2E verschlüsselt) No comment provided by engineer. + + author + member role + bad message ID Ungültige Nachrichten-ID @@ -6576,6 +6636,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. v%@ + v%@ No comment provided by engineer. @@ -6717,6 +6778,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX nutzt den lokalen Netzwerkzugriff, um die Nutzung von Benutzer-Chatprofilen über eine Desktop-App im gleichen Netzwerk zu erlauben. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 6d70bd1221..23498b2128 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1194,6 +1194,11 @@ Connect server test step + + Connect automatically + Connect automatically + No comment provided by engineer. + Connect incognito Connect incognito @@ -1908,9 +1913,9 @@ This cannot be undone! Discover and join groups No comment provided by engineer. - - Discover on network - Discover on network + + Discover via local network + Discover via local network No comment provided by engineer. @@ -2553,6 +2558,11 @@ This cannot be undone! For console No comment provided by engineer. + + Found desktop + Found desktop + No comment provided by engineer. + French interface French interface @@ -3518,6 +3528,11 @@ This is your link for group %@! No received or sent files No comment provided by engineer. + + Not compatible! + Not compatible! + No comment provided by engineer. + Notifications Notifications @@ -5488,6 +5503,11 @@ To connect, please ask your contact to create another connection link and check Voice message… No comment provided by engineer. + + Waiting for desktop... + Waiting for desktop... + No comment provided by engineer. + Waiting for file Waiting for file @@ -6033,6 +6053,11 @@ SimpleX servers cannot see your profile. audio call (not e2e encrypted) No comment provided by engineer. + + author + author + member role + bad message ID bad message ID diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index cc93c0531d..ccc7ee3446 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -1172,6 +1172,10 @@ Conectar server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Conectar incognito @@ -1862,8 +1866,8 @@ This cannot be undone! Descubre y únete a grupos No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2499,6 +2503,10 @@ This cannot be undone! Para consola No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Interfaz en francés @@ -3449,6 +3457,10 @@ This is your link for group %@! Sin archivos recibidos o enviados No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Notificaciones @@ -5397,6 +5409,10 @@ Para conectarte, pide a tu contacto que cree otro enlace de conexión y comprueb Mensaje de voz… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Esperando archivo @@ -5927,6 +5943,10 @@ Los servidores de SimpleX no pueden ver tu perfil. llamada (sin cifrar) No comment provided by engineer. + + author + member role + bad message ID ID de mensaje erróneo diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index c1c46aa42a..cf161efae4 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -1167,6 +1167,10 @@ Yhdistä server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Yhdistä Incognito @@ -1857,8 +1861,8 @@ This cannot be undone! Löydä ryhmiä ja liity niihin No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2491,6 +2495,10 @@ This cannot be undone! Konsoliin No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Ranskalainen käyttöliittymä @@ -3440,6 +3448,10 @@ This is your link for group %@! Ei vastaanotettuja tai lähetettyjä tiedostoja No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Ilmoitukset @@ -5383,6 +5395,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Ääniviesti… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Odottaa tiedostoa @@ -5913,6 +5929,10 @@ SimpleX-palvelimet eivät näe profiiliasi. äänipuhelu (ei e2e-salattu) No comment provided by engineer. + + author + member role + bad message ID virheellinen viestin tunniste diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 78a23315cb..af54b7ceb9 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -299,10 +299,12 @@ (new) + (nouveau) No comment provided by engineer. (this device v%@) + (cet appareil v%@) No comment provided by engineer. @@ -397,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - option pour notifier les contacts supprimés. +- noms de profil avec espaces. +- et plus encore ! No comment provided by engineer. @@ -885,6 +890,7 @@ Bad desktop address + Mauvaise adresse de bureau No comment provided by engineer. @@ -899,6 +905,7 @@ Better groups + Des groupes plus performants No comment provided by engineer. @@ -913,6 +920,7 @@ Block group members + Bloquer des membres d'un groupe No comment provided by engineer. @@ -1186,6 +1194,10 @@ Se connecter server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Se connecter incognito @@ -1193,6 +1205,7 @@ Connect to desktop + Se connecter au bureau No comment provided by engineer. @@ -1241,10 +1254,12 @@ Il s'agit de votre propre lien unique ! Connected desktop + Bureau connecté No comment provided by engineer. Connected to desktop + Connecté au bureau No comment provided by engineer. @@ -1259,6 +1274,7 @@ Il s'agit de votre propre lien unique ! Connecting to desktop + Connexion au bureau No comment provided by engineer. @@ -1283,6 +1299,7 @@ Il s'agit de votre propre lien unique ! Connection terminated + Connexion terminée No comment provided by engineer. @@ -1367,6 +1384,7 @@ Il s'agit de votre propre lien unique ! Create a group using a random profile. + Création de groupes via un profil aléatoire. No comment provided by engineer. @@ -1781,14 +1799,17 @@ Cette opération ne peut être annulée ! Desktop address + Adresse de bureau No comment provided by engineer. Desktop app version %@ is not compatible with this app. + La version de l'application de bureau %@ n'est pas compatible avec cette application. No comment provided by engineer. Desktop devices + Appareils de bureau No comment provided by engineer. @@ -1883,6 +1904,7 @@ Cette opération ne peut être annulée ! Disconnect desktop? + Déconnecter le bureau ? No comment provided by engineer. @@ -1890,8 +1912,8 @@ Cette opération ne peut être annulée ! Découvrir et rejoindre des groupes No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2066,10 +2088,12 @@ Cette opération ne peut être annulée ! Encryption re-negotiation error + Erreur lors de la renégociation du chiffrement message decrypt error item Encryption re-negotiation failed. + La renégociation du chiffrement a échoué. No comment provided by engineer. @@ -2104,6 +2128,7 @@ Cette opération ne peut être annulée ! Enter this device name… + Entrez le nom de l'appareil… No comment provided by engineer. @@ -2433,6 +2458,7 @@ Cette opération ne peut être annulée ! Faster joining and more reliable messages. + Connexion plus rapide et messages plus fiables. No comment provided by engineer. @@ -2530,6 +2556,10 @@ Cette opération ne peut être annulée ! Pour la console No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Interface en français @@ -2857,6 +2887,7 @@ Cette opération ne peut être annulée ! Incognito groups + Groupes incognito No comment provided by engineer. @@ -2891,6 +2922,7 @@ Cette opération ne peut être annulée ! Incompatible version + Version incompatible No comment provided by engineer. @@ -3065,6 +3097,7 @@ Voici votre lien pour le groupe %@ ! Keep the app open to use it from desktop + Garder l'application ouverte pour l'utiliser depuis le bureau No comment provided by engineer. @@ -3129,14 +3162,17 @@ Voici votre lien pour le groupe %@ ! Link mobile and desktop apps! 🔗 + Liez vos applications mobiles et de bureau ! 🔗 No comment provided by engineer. Linked desktop options + Options de bureau lié No comment provided by engineer. Linked desktops + Bureaux liés No comment provided by engineer. @@ -3489,6 +3525,10 @@ Voici votre lien pour le groupe %@ ! Aucun fichier reçu ou envoyé No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Notifications @@ -3710,6 +3750,7 @@ Voici votre lien pour le groupe %@ ! Paste desktop address + Coller l'adresse du bureau No comment provided by engineer. @@ -4309,6 +4350,7 @@ Voici votre lien pour le groupe %@ ! Scan QR code from desktop + Scanner le code QR du bureau No comment provided by engineer. @@ -4533,6 +4575,7 @@ Voici votre lien pour le groupe %@ ! Session code + Code de session No comment provided by engineer. @@ -5034,6 +5077,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. This device name + Ce nom d'appareil No comment provided by engineer. @@ -5073,6 +5117,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. To hide unwanted messages. + Pour cacher les messages indésirables. No comment provided by engineer. @@ -5236,10 +5281,12 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Unlink + Délier No comment provided by engineer. Unlink desktop? + Délier le bureau ? No comment provided by engineer. @@ -5334,6 +5381,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use from desktop + Utilisation depuis le bureau No comment provided by engineer. @@ -5368,10 +5416,12 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Verify code with desktop + Vérifier le code avec le bureau No comment provided by engineer. Verify connection + Vérifier la connexion No comment provided by engineer. @@ -5381,6 +5431,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Verify connections + Vérifier les connexions No comment provided by engineer. @@ -5395,6 +5446,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Via secure quantum resistant protocol. + Via un protocole sécurisé de cryptographie post-quantique. No comment provided by engineer. @@ -5447,6 +5499,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Message vocal… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file En attente du fichier @@ -5992,6 +6048,10 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. appel audio (sans chiffrement) No comment provided by engineer. + + author + member role + bad message ID ID de message incorrecte @@ -6576,6 +6636,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. v%@ + v%@ No comment provided by engineer. @@ -6717,6 +6778,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX utilise un accès au réseau local pour permettre l'utilisation du profil de chat de l'utilisateur via l'application de bureau au sein de ce même réseau. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 6f99b16b77..bf1b1ee386 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -399,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - avvisa facoltativamente i contatti eliminati. +- nomi del profilo con spazi. +- e molto altro! No comment provided by engineer. @@ -902,6 +905,7 @@ Better groups + Gruppi migliorati No comment provided by engineer. @@ -916,6 +920,7 @@ Block group members + Blocca i membri dei gruppi No comment provided by engineer. @@ -1189,6 +1194,10 @@ Connetti server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Connetti in incognito @@ -1375,6 +1384,7 @@ Questo è il tuo link una tantum! Create a group using a random profile. + Crea un gruppo usando un profilo casuale. No comment provided by engineer. @@ -1902,9 +1912,8 @@ Non è reversibile! Scopri ed unisciti ai gruppi No comment provided by engineer. - - Discover on network - Trova nella rete + + Discover via local network No comment provided by engineer. @@ -2449,6 +2458,7 @@ Non è reversibile! Faster joining and more reliable messages. + Ingresso più veloce e messaggi più affidabili. No comment provided by engineer. @@ -2546,6 +2556,10 @@ Non è reversibile! Per console No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Interfaccia francese @@ -2873,6 +2887,7 @@ Non è reversibile! Incognito groups + Gruppi in incognito No comment provided by engineer. @@ -3147,6 +3162,7 @@ Questo è il tuo link per il gruppo %@! Link mobile and desktop apps! 🔗 + Collega le app mobile e desktop! 🔗 No comment provided by engineer. @@ -3509,6 +3525,10 @@ Questo è il tuo link per il gruppo %@! Nessun file ricevuto o inviato No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Notifiche @@ -5097,6 +5117,7 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa. To hide unwanted messages. + Per nascondere messaggi indesiderati. No comment provided by engineer. @@ -5425,6 +5446,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Via secure quantum resistant protocol. + Tramite protocollo sicuro resistente alla quantistica. No comment provided by engineer. @@ -5477,6 +5499,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Messaggio vocale… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file In attesa del file @@ -6022,6 +6048,10 @@ I server di SimpleX non possono vedere il tuo profilo. chiamata audio (non crittografata e2e) No comment provided by engineer. + + author + member role + bad message ID ID messaggio errato diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index bae6d393d0..11ca09ba3b 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1169,6 +1169,10 @@ 接続 server test step + + Connect automatically + No comment provided by engineer. + Connect incognito シークレットモードで接続 @@ -1859,8 +1863,8 @@ This cannot be undone! グループを見つけて参加する No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2494,6 +2498,10 @@ This cannot be undone! コンソール No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface フランス語UI @@ -3443,6 +3451,10 @@ This is your link for group %@! 送受信済みのファイルがありません No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications 通知 @@ -5379,6 +5391,10 @@ To connect, please ask your contact to create another connection link and check 音声メッセージ… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file ファイル待ち @@ -5909,6 +5925,10 @@ SimpleX サーバーはあなたのプロファイルを参照できません。 音声通話 (エンドツーエンド暗号化なし) No comment provided by engineer. + + author + member role + bad message ID メッセージ ID が正しくありません diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 8b6633c3db..d2be34ec6c 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -399,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - optioneel verwijderde contacten op de hoogte stellen. +- profielnamen met spaties. +- en meer! No comment provided by engineer. @@ -902,6 +905,7 @@ Better groups + Betere groepen No comment provided by engineer. @@ -916,6 +920,7 @@ Block group members + Groepsleden blokkeren No comment provided by engineer. @@ -1189,6 +1194,10 @@ Verbind server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Verbind incognito @@ -1375,6 +1384,7 @@ Dit is uw eigen eenmalige link! Create a group using a random profile. + Maak een groep met een willekeurig profiel. No comment provided by engineer. @@ -1902,9 +1912,8 @@ Dit kan niet ongedaan gemaakt worden! Ontdek en sluit je aan bij groepen No comment provided by engineer. - - Discover on network - Ontdek via netwerk + + Discover via local network No comment provided by engineer. @@ -2449,6 +2458,7 @@ Dit kan niet ongedaan gemaakt worden! Faster joining and more reliable messages. + Snellere deelname en betrouwbaardere berichten. No comment provided by engineer. @@ -2546,6 +2556,10 @@ Dit kan niet ongedaan gemaakt worden! Voor console No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Franse interface @@ -2873,6 +2887,7 @@ Dit kan niet ongedaan gemaakt worden! Incognito groups + Incognitogroepen No comment provided by engineer. @@ -3147,6 +3162,7 @@ Dit is jouw link voor groep %@! Link mobile and desktop apps! 🔗 + Koppel mobiele en desktop-apps! 🔗 No comment provided by engineer. @@ -3509,6 +3525,10 @@ Dit is jouw link voor groep %@! Geen ontvangen of verzonden bestanden No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Meldingen @@ -5097,6 +5117,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. To hide unwanted messages. + Om ongewenste berichten te verbergen. No comment provided by engineer. @@ -5425,6 +5446,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Via secure quantum resistant protocol. + Via een beveiligd kwantumbestendig protocol. No comment provided by engineer. @@ -5477,6 +5499,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Spraakbericht… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Wachten op bestand @@ -6022,6 +6048,10 @@ SimpleX servers kunnen uw profiel niet zien. audio oproep (niet e2e versleuteld) No comment provided by engineer. + + author + member role + bad message ID Onjuiste bericht-ID diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 22d10aa434..61147d7f98 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -1186,6 +1186,10 @@ Połącz server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Połącz incognito @@ -1890,8 +1894,8 @@ To nie może być cofnięte! Odkrywaj i dołączaj do grup No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2530,6 +2534,10 @@ To nie może być cofnięte! Dla konsoli No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Francuski interfejs @@ -3489,6 +3497,10 @@ To jest twój link do grupy %@! Brak odebranych lub wysłanych plików No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Powiadomienia @@ -5447,6 +5459,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Wiadomość głosowa… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Oczekiwanie na plik @@ -5992,6 +6008,10 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. połączenie audio (nie szyfrowane e2e) No comment provided by engineer. + + author + member role + bad message ID zły identyfikator wiadomości diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 7fd4cd51b3..abd3d01d87 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -89,6 +89,7 @@ %@ and %@ + %@ и %@ No comment provided by engineer. @@ -103,6 +104,7 @@ %@ connected + %@ соединен(а) No comment provided by engineer. @@ -132,6 +134,7 @@ %@, %@ and %lld members + %@, %@ и %lld членов группы No comment provided by engineer. @@ -201,6 +204,7 @@ %lld group events + %lld событий No comment provided by engineer. @@ -210,14 +214,17 @@ %lld messages blocked + %lld сообщений заблокировано No comment provided by engineer. %lld messages marked deleted + %lld сообщений помечено удалёнными No comment provided by engineer. %lld messages moderated by %@ + %lld сообщений модерировано членом %@ No comment provided by engineer. @@ -292,10 +299,12 @@ (new) + (новое) No comment provided by engineer. (this device v%@) + (это устройство v%@) No comment provided by engineer. @@ -390,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - опционально уведомляйте удалённые контакты. +- имена профилей с пробелами. +- и прочее! No comment provided by engineer. @@ -408,6 +420,7 @@ 0 sec + 0 сек time to disappear @@ -637,6 +650,7 @@ All new messages from %@ will be hidden! + Все новые сообщения от %@ будут скрыты! No comment provided by engineer. @@ -746,10 +760,12 @@ Already connecting! + Уже соединяется! No comment provided by engineer. Already joining the group! + Вступление в группу уже начато! No comment provided by engineer. @@ -874,6 +890,7 @@ Bad desktop address + Неверный адрес компьютера No comment provided by engineer. @@ -888,6 +905,7 @@ Better groups + Улучшенные группы No comment provided by engineer. @@ -897,18 +915,22 @@ Block + Заблокировать No comment provided by engineer. Block group members + Блокируйте членов группы No comment provided by engineer. Block member + Заблокировать члена группы No comment provided by engineer. Block member? + Заблокировать члена группы? No comment provided by engineer. @@ -1172,6 +1194,10 @@ Соединиться server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Соединиться Инкогнито @@ -1179,24 +1205,31 @@ Connect to desktop + Подключиться к компьютеру No comment provided by engineer. Connect to yourself? + Соединиться с самим собой? No comment provided by engineer. Connect to yourself? This is your own SimpleX address! + Соединиться с самим собой? +Это ваш собственный адрес SimpleX! No comment provided by engineer. Connect to yourself? This is your own one-time link! + Соединиться с самим собой? +Это ваша собственная одноразовая ссылка! No comment provided by engineer. Connect via contact address + Соединиться через адрес No comment provided by engineer. @@ -1216,14 +1249,17 @@ This is your own one-time link! Connect with %@ + Соединиться с %@ No comment provided by engineer. Connected desktop + Подключенный компьютер No comment provided by engineer. Connected to desktop + Компьютер подключен No comment provided by engineer. @@ -1238,6 +1274,7 @@ This is your own one-time link! Connecting to desktop + Подключение к компьютеру No comment provided by engineer. @@ -1262,6 +1299,7 @@ This is your own one-time link! Connection terminated + Подключение прервано No comment provided by engineer. @@ -1331,6 +1369,7 @@ This is your own one-time link! Correct name to %@? + Исправить имя на %@? No comment provided by engineer. @@ -1345,6 +1384,7 @@ This is your own one-time link! Create a group using a random profile. + Создайте группу, используя случайный профиль. No comment provided by engineer. @@ -1359,6 +1399,7 @@ This is your own one-time link! Create group + Создать группу No comment provided by engineer. @@ -1383,6 +1424,7 @@ This is your own one-time link! Create profile + Создать профиль No comment provided by engineer. @@ -1545,6 +1587,7 @@ This is your own one-time link! Delete %lld messages? + Удалить %lld сообщений? No comment provided by engineer. @@ -1574,6 +1617,7 @@ This is your own one-time link! Delete and notify contact + Удалить и уведомить контакт No comment provided by engineer. @@ -1609,6 +1653,8 @@ This is your own one-time link! Delete contact? This cannot be undone! + Удалить контакт? +Это не может быть отменено! No comment provided by engineer. @@ -1753,14 +1799,17 @@ This cannot be undone! Desktop address + Адрес компьютера No comment provided by engineer. Desktop app version %@ is not compatible with this app. + Версия настольного приложения %@ несовместима с этим приложением. No comment provided by engineer. Desktop devices + Компьютеры No comment provided by engineer. @@ -1855,6 +1904,7 @@ This cannot be undone! Disconnect desktop? + Отключить компьютер? No comment provided by engineer. @@ -1862,8 +1912,8 @@ This cannot be undone! Найдите и вступите в группы No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2038,10 +2088,12 @@ This cannot be undone! Encryption re-negotiation error + Ошибка нового соглашения о шифровании message decrypt error item Encryption re-negotiation failed. + Ошибка нового соглашения о шифровании. No comment provided by engineer. @@ -2056,6 +2108,7 @@ This cannot be undone! Enter group name… + Введите имя группы… No comment provided by engineer. @@ -2075,6 +2128,7 @@ This cannot be undone! Enter this device name… + Введите имя этого устройства… No comment provided by engineer. @@ -2089,6 +2143,7 @@ This cannot be undone! Enter your name… + Введите ваше имя… No comment provided by engineer. @@ -2148,6 +2203,7 @@ This cannot be undone! Error creating member contact + Ошибка создания контакта с членом группы No comment provided by engineer. @@ -2282,6 +2338,7 @@ This cannot be undone! Error sending member contact invitation + Ошибка отправки приглашения члену группы No comment provided by engineer. @@ -2366,6 +2423,7 @@ This cannot be undone! Expand + Раскрыть chat item action @@ -2400,6 +2458,7 @@ This cannot be undone! Faster joining and more reliable messages. + Быстрое вступление и надежная доставка сообщений. No comment provided by engineer. @@ -2497,6 +2556,10 @@ This cannot be undone! Для консоли No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Французский интерфейс @@ -2519,6 +2582,7 @@ This cannot be undone! Fully decentralized – visible only to members. + Группа полностью децентрализована – она видна только членам. No comment provided by engineer. @@ -2543,10 +2607,12 @@ This cannot be undone! Group already exists + Группа уже существует No comment provided by engineer. Group already exists! + Группа уже существует! No comment provided by engineer. @@ -2821,6 +2887,7 @@ This cannot be undone! Incognito groups + Инкогнито группы No comment provided by engineer. @@ -2855,6 +2922,7 @@ This cannot be undone! Incompatible version + Несовместимая версия No comment provided by engineer. @@ -2906,6 +2974,7 @@ This cannot be undone! Invalid name! + Неверное имя! No comment provided by engineer. @@ -3001,6 +3070,7 @@ This cannot be undone! Join group? + Вступить в группу? No comment provided by engineer. @@ -3010,11 +3080,14 @@ This cannot be undone! Join with current profile + Вступить с активным профилем No comment provided by engineer. Join your group? This is your link for group %@! + Вступить в вашу группу? +Это ваша ссылка на группу %@! No comment provided by engineer. @@ -3024,6 +3097,7 @@ This is your link for group %@! Keep the app open to use it from desktop + Оставьте приложение открытым, чтобы использовать его с компьютера No comment provided by engineer. @@ -3088,14 +3162,17 @@ This is your link for group %@! Link mobile and desktop apps! 🔗 + Свяжите мобильное и настольное приложения! 🔗 No comment provided by engineer. Linked desktop options + Опции связанных компьютеров No comment provided by engineer. Linked desktops + Связанные компьютеры No comment provided by engineer. @@ -3250,6 +3327,7 @@ This is your link for group %@! Messages from %@ will be shown! + Сообщения от %@ будут показаны! No comment provided by engineer. @@ -3447,6 +3525,10 @@ This is your link for group %@! Нет полученных или отправленных файлов No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Уведомления @@ -3583,6 +3665,7 @@ This is your link for group %@! Open + Открыть No comment provided by engineer. @@ -3602,6 +3685,7 @@ This is your link for group %@! Open group + Открыть группу No comment provided by engineer. @@ -3666,6 +3750,7 @@ This is your link for group %@! Paste desktop address + Вставить адрес компьютера No comment provided by engineer. @@ -3815,10 +3900,12 @@ This is your link for group %@! Profile name + Имя профиля No comment provided by engineer. Profile name: + Имя профиля: No comment provided by engineer. @@ -4068,10 +4155,12 @@ This is your link for group %@! Repeat connection request? + Повторить запрос на соединение? No comment provided by engineer. Repeat join request? + Повторить запрос на вступление? No comment provided by engineer. @@ -4261,6 +4350,7 @@ This is your link for group %@! Scan QR code from desktop + Сканировать QR код с компьютера No comment provided by engineer. @@ -4345,6 +4435,7 @@ This is your link for group %@! Send direct message to connect + Отправьте сообщение чтобы соединиться No comment provided by engineer. @@ -4484,6 +4575,7 @@ This is your link for group %@! Session code + Код сессии No comment provided by engineer. @@ -4798,6 +4890,7 @@ This is your link for group %@! Tap to Connect + Нажмите чтобы соединиться No comment provided by engineer. @@ -4984,6 +5077,7 @@ It can happen because of some bug or when the connection is compromised. This device name + Имя этого устройства No comment provided by engineer. @@ -4998,10 +5092,12 @@ It can happen because of some bug or when the connection is compromised. This is your own SimpleX address! + Это ваш собственный адрес SimpleX! No comment provided by engineer. This is your own one-time link! + Это ваша собственная одноразовая ссылка! No comment provided by engineer. @@ -5021,6 +5117,7 @@ It can happen because of some bug or when the connection is compromised. To hide unwanted messages. + Чтобы скрыть нежелательные сообщения. No comment provided by engineer. @@ -5107,14 +5204,17 @@ You will be prompted to complete authentication before this feature is enabled.< Unblock + Разблокировать No comment provided by engineer. Unblock member + Разблокировать члена группы No comment provided by engineer. Unblock member? + Разблокировать члена группы? No comment provided by engineer. @@ -5181,10 +5281,12 @@ To connect, please ask your contact to create another connection link and check Unlink + Забыть No comment provided by engineer. Unlink desktop? + Забыть компьютер? No comment provided by engineer. @@ -5279,6 +5381,7 @@ To connect, please ask your contact to create another connection link and check Use from desktop + Использовать с компьютера No comment provided by engineer. @@ -5313,10 +5416,12 @@ To connect, please ask your contact to create another connection link and check Verify code with desktop + Сверьте код с компьютером No comment provided by engineer. Verify connection + Проверить соединение No comment provided by engineer. @@ -5326,6 +5431,7 @@ To connect, please ask your contact to create another connection link and check Verify connections + Проверять соединения No comment provided by engineer. @@ -5340,6 +5446,7 @@ To connect, please ask your contact to create another connection link and check Via secure quantum resistant protocol. + Через безопасный квантово-устойчивый протокол. No comment provided by engineer. @@ -5392,6 +5499,10 @@ To connect, please ask your contact to create another connection link and check Голосовое сообщение… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Ожидается прием файла @@ -5494,31 +5605,39 @@ To connect, please ask your contact to create another connection link and check You are already connecting to %@. + Вы уже соединяетесь с %@. No comment provided by engineer. You are already connecting via this one-time link! + Вы уже соединяетесь по этой одноразовой ссылке! No comment provided by engineer. You are already in group %@. + Вы уже состоите в группе %@. No comment provided by engineer. You are already joining the group %@. + Вы уже вступаете в группу %@. No comment provided by engineer. You are already joining the group via this link! + Вы уже вступаете в группу по этой ссылке! No comment provided by engineer. You are already joining the group via this link. + Вы уже вступаете в группу по этой ссылке. No comment provided by engineer. You are already joining the group! Repeat join request? + Вы уже вступаете в группу! +Повторить запрос на вступление? No comment provided by engineer. @@ -5618,11 +5737,14 @@ Repeat join request? You have already requested connection via this address! + Вы уже запросили соединение через этот адрес! No comment provided by engineer. You have already requested connection! Repeat connection request? + Вы уже запросили соединение! +Повторить запрос? No comment provided by engineer. @@ -5677,6 +5799,7 @@ Repeat connection request? You will be connected when group link host's device is online, please wait or check later! + Соединение будет установлено, когда владелец ссылки группы будет онлайн. Пожалуйста, подождите или проверьте позже! No comment provided by engineer. @@ -5696,6 +5819,7 @@ Repeat connection request? You will connect to all group members. + Вы соединитесь со всеми членами группы. No comment provided by engineer. @@ -5819,6 +5943,7 @@ You can change it in Settings. Your profile + Ваш профиль No comment provided by engineer. @@ -5915,6 +6040,7 @@ SimpleX серверы не могут получить доступ к Ваше and %lld other events + и %lld других событий No comment provided by engineer. @@ -5922,6 +6048,10 @@ SimpleX серверы не могут получить доступ к Ваше аудиозвонок (не e2e зашифрованный) No comment provided by engineer. + + author + member role + bad message ID ошибка ID сообщения @@ -5934,6 +6064,7 @@ SimpleX серверы не могут получить доступ к Ваше blocked + заблокировано No comment provided by engineer. @@ -6008,6 +6139,7 @@ SimpleX серверы не могут получить доступ к Ваше connected directly + соединен(а) напрямую rcv group event chat item @@ -6107,6 +6239,7 @@ SimpleX серверы не могут получить доступ к Ваше deleted contact + удалил(а) контакт rcv direct event chat item @@ -6473,6 +6606,7 @@ SimpleX серверы не могут получить доступ к Ваше send direct message + отправьте сообщение No comment provided by engineer. @@ -6502,6 +6636,7 @@ SimpleX серверы не могут получить доступ к Ваше v%@ + v%@ No comment provided by engineer. @@ -6643,6 +6778,7 @@ SimpleX серверы не могут получить доступ к Ваше SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX использует доступ к локальной сети, чтобы разрешить использование профиля чата через компьютер в той же сети. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 6ada6dbbbe..bcb39e6e03 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1159,6 +1159,10 @@ เชื่อมต่อ server test step + + Connect automatically + No comment provided by engineer. + Connect incognito No comment provided by engineer. @@ -1844,8 +1848,8 @@ This cannot be undone! Discover and join groups No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2476,6 +2480,10 @@ This cannot be undone! สำหรับคอนโซล No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface อินเทอร์เฟซภาษาฝรั่งเศส @@ -3421,6 +3429,10 @@ This is your link for group %@! ไม่มีไฟล์ที่ได้รับหรือส่ง No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications การแจ้งเตือน @@ -5354,6 +5366,10 @@ To connect, please ask your contact to create another connection link and check ข้อความเสียง… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file กำลังรอไฟล์ @@ -5882,6 +5898,10 @@ SimpleX servers cannot see your profile. การโทรด้วยเสียง (ไม่ได้ encrypt จากต้นจนจบ) No comment provided by engineer. + + author + member role + bad message ID ID ข้อความที่ไม่ดี diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 868f420630..abd58231a9 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1166,6 +1166,10 @@ Підключіться server test step + + Connect automatically + No comment provided by engineer. + Connect incognito Підключайтеся інкогніто @@ -1854,8 +1858,8 @@ This cannot be undone! Discover and join groups No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2486,6 +2490,10 @@ This cannot be undone! Для консолі No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface Французький інтерфейс @@ -3435,6 +3443,10 @@ This is your link for group %@! Немає отриманих або відправлених файлів No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications Сповіщення @@ -5378,6 +5390,10 @@ To connect, please ask your contact to create another connection link and check Голосове повідомлення… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file Очікування файлу @@ -5908,6 +5924,10 @@ SimpleX servers cannot see your profile. аудіовиклик (без шифрування e2e) No comment provided by engineer. + + author + member role + bad message ID невірний ідентифікатор повідомлення diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 77374aefa5..d96537be3e 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -1172,6 +1172,10 @@ 连接 server test step + + Connect automatically + No comment provided by engineer. + Connect incognito 在隐身状态下连接 @@ -1862,8 +1866,8 @@ This cannot be undone! 发现和加入群组 No comment provided by engineer. - - Discover on network + + Discover via local network No comment provided by engineer. @@ -2499,6 +2503,10 @@ This cannot be undone! 用于控制台 No comment provided by engineer. + + Found desktop + No comment provided by engineer. + French interface 法语界面 @@ -3449,6 +3457,10 @@ This is your link for group %@! 未收到或发送文件 No comment provided by engineer. + + Not compatible! + No comment provided by engineer. + Notifications 通知 @@ -5396,6 +5408,10 @@ To connect, please ask your contact to create another connection link and check 语音消息…… No comment provided by engineer. + + Waiting for desktop... + No comment provided by engineer. + Waiting for file 等待文件中 @@ -5926,6 +5942,10 @@ SimpleX 服务器无法看到您的资料。 语音通话(非端到端加密) No comment provided by engineer. + + author + member role + bad message ID 错误消息 ID diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 00489be829..c860a24998 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilere Zustellung von Nachrichten.\n- ein bisschen verbesserte Gruppen.\n- und mehr!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- Optionale Benachrichtigung von gelöschten Kontakten.\n- Profilnamen mit Leerzeichen.\n- Und mehr!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- Bis zu 5 Minuten lange Sprachnachrichten.\n- Zeitdauer für verschwindende Nachrichten anpassen.\n- Nachrichten-Historie bearbeiten."; @@ -43,6 +46,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(Neu)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(Dieses Gerät hat v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -548,6 +557,9 @@ /* No comment provided by engineer. */ "Back" = "Zurück"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Falsche Desktop-Adresse"; + /* integrity error chat item */ "bad message hash" = "Ungültiger Nachrichten-Hash"; @@ -560,12 +572,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "Falsche Nachrichten-ID"; +/* No comment provided by engineer. */ +"Better groups" = "Bessere Gruppen"; + /* No comment provided by engineer. */ "Better messages" = "Verbesserungen bei Nachrichten"; /* No comment provided by engineer. */ "Block" = "Blockieren"; +/* No comment provided by engineer. */ +"Block group members" = "Gruppenmitglieder blockieren"; + /* No comment provided by engineer. */ "Block member" = "Mitglied blockieren"; @@ -771,6 +789,9 @@ /* No comment provided by engineer. */ "Connect incognito" = "Inkognito verbinden"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Mit dem Desktop verbinden"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "Mit den SimpleX Chat-Entwicklern verbinden."; @@ -801,9 +822,15 @@ /* No comment provided by engineer. */ "connected" = "Verbunden"; +/* No comment provided by engineer. */ +"Connected desktop" = "Verbundener Desktop"; + /* rcv group event chat item */ "connected directly" = "Direkt miteinander verbunden"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Mit dem Desktop verbunden"; + /* No comment provided by engineer. */ "connecting" = "verbinde"; @@ -828,6 +855,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Mit dem Server verbinden… (Fehler: %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Mit dem Desktop verbinden"; + /* chat list item title */ "connecting…" = "Verbinde…"; @@ -846,6 +876,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Verbindungsanfrage wurde gesendet!"; +/* No comment provided by engineer. */ +"Connection terminated" = "Verbindung beendet"; + /* No comment provided by engineer. */ "Connection timeout" = "Verbindungszeitüberschreitung"; @@ -900,6 +933,9 @@ /* No comment provided by engineer. */ "Create" = "Erstellen"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Erstellen Sie eine Gruppe mit einem zufälligen Profil."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Erstellen Sie eine Adresse, damit sich Personen mit Ihnen verbinden können."; @@ -1173,6 +1209,15 @@ /* No comment provided by engineer. */ "Description" = "Beschreibung"; +/* No comment provided by engineer. */ +"Desktop address" = "Desktop-Adresse"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "Desktop App-Version %@ ist mit dieser App nicht kompatibel."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Desktop-Geräte"; + /* No comment provided by engineer. */ "Develop" = "Entwicklung"; @@ -1236,6 +1281,9 @@ /* server test step */ "Disconnect" = "Trennen"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Desktop-Verbindung trennen?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Gruppen entdecken und ihnen beitreten"; @@ -1374,6 +1422,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "Neuaushandlung der Verschlüsselung von %@ erlaubt"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Fehler bei der Neuverhandlung der Verschlüsselung"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "Neuverhandlung der Verschlüsselung fehlgeschlagen."; + /* chat item text */ "encryption re-negotiation required" = "Neuaushandlung der Verschlüsselung notwendig"; @@ -1404,6 +1458,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Geben Sie den Server manuell ein"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Geben Sie diesen Gerätenamen ein…"; + /* placeholder */ "Enter welcome message…" = "Geben Sie eine Begrüßungsmeldung ein …"; @@ -1605,6 +1662,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Schnell und ohne warten auf den Absender, bis er online ist!"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung."; + /* No comment provided by engineer. */ "Favorite" = "Favorit"; @@ -1866,6 +1926,9 @@ /* No comment provided by engineer. */ "Incognito" = "Inkognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Inkognito-Gruppen"; + /* No comment provided by engineer. */ "Incognito mode" = "Inkognito-Modus"; @@ -1893,6 +1956,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Inkompatible Datenbank-Version"; +/* No comment provided by engineer. */ +"Incompatible version" = "Inkompatible Version"; + /* PIN entry */ "Incorrect passcode" = "Zugangscode ist falsch"; @@ -2028,6 +2094,9 @@ /* No comment provided by engineer. */ "Joining group" = "Der Gruppe beitreten"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Die App muss geöffnet bleiben, um sie vom Desktop aus nutzen zu können"; + /* No comment provided by engineer. */ "Keep your connections" = "Ihre Verbindungen beibehalten"; @@ -2064,6 +2133,15 @@ /* No comment provided by engineer. */ "Limitations" = "Einschränkungen"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Verknüpfe Mobiltelefon- und Desktop-Apps! 🔗"; + +/* No comment provided by engineer. */ +"Linked desktop options" = "Verknüpfte Desktop-Optionen"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Verknüpfte Desktops"; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2462,6 +2540,9 @@ /* No comment provided by engineer. */ "Paste" = "Einfügen"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Desktop-Adresse einfügen"; + /* No comment provided by engineer. */ "Paste image" = "Bild einfügen"; @@ -2846,6 +2927,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "QR-Code scannen"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Den QR-Code vom Desktop scannen"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Scannen Sie den Sicherheitscode von der App Ihres Kontakts."; @@ -2990,6 +3074,9 @@ /* No comment provided by engineer. */ "Servers" = "Server"; +/* No comment provided by engineer. */ +"Session code" = "Sitzungscode"; + /* No comment provided by engineer. */ "Set 1 day" = "Einen Tag festlegen"; @@ -3299,6 +3386,9 @@ /* notification title */ "this contact" = "Dieser Kontakt"; +/* No comment provided by engineer. */ +"This device name" = "Dieser Gerätename"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Es werden keine Empfangsbestätigungen gesendet, da diese Gruppe über %lld Mitglieder hat."; @@ -3320,6 +3410,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Um eine Verbindung herzustellen, kann Ihr Kontakt den QR-Code scannen oder den Link in der App verwenden."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Um unerwünschte Nachrichten zu verbergen."; + /* No comment provided by engineer. */ "To make a new connection" = "Um eine Verbindung mit einem neuen Kontakt zu erstellen"; @@ -3416,6 +3509,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Entweder hat Ihr Kontakt die Verbindung gelöscht, oder dieser Link wurde bereits verwendet, es könnte sich um einen Fehler handeln - Bitte melden Sie es uns.\nBitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um sich neu verbinden zu können und stellen Sie sicher, dass Sie eine stabile Netzwerk-Verbindung haben."; +/* No comment provided by engineer. */ +"Unlink" = "Entkoppeln"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Desktop entkoppeln?"; + /* No comment provided by engineer. */ "Unlock" = "Entsperren"; @@ -3470,6 +3569,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Für neue Verbindungen nutzen"; +/* No comment provided by engineer. */ +"Use from desktop" = "Vom Desktop aus nutzen"; + /* No comment provided by engineer. */ "Use iOS call interface" = "iOS Anrufschnittstelle nutzen"; @@ -3491,12 +3593,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "Verwendung von SimpleX-Chat-Servern."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Code mit dem Desktop überprüfen"; + +/* No comment provided by engineer. */ +"Verify connection" = "Verbindung überprüfen"; + /* No comment provided by engineer. */ "Verify connection security" = "Sicherheit der Verbindung überprüfen"; +/* No comment provided by engineer. */ +"Verify connections" = "Verbindungen überprüfen"; + /* No comment provided by engineer. */ "Verify security code" = "Sicherheitscode überprüfen"; @@ -3515,6 +3629,9 @@ /* No comment provided by engineer. */ "via relay" = "über Relais"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Über ein sicheres quantenbeständiges Protokoll."; + /* No comment provided by engineer. */ "Video call" = "Videoanruf"; diff --git a/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings index 6a33e41774..5fe2ef2d09 100644 --- a/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/de.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "Face ID wird von SimpleX für die lokale Authentifizierung genutzt"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX nutzt den lokalen Netzwerkzugriff, um die Nutzung von Benutzer-Chatprofilen über eine Desktop-App im gleichen Netzwerk zu erlauben."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX benötigt Zugriff auf das Mikrofon, um Audio- und Videoanrufe und die Aufnahme von Sprachnachrichten zu ermöglichen."; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 33f571b513..b71d510e72 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- une diffusion plus stable des messages.\n- des groupes un peu plus performants.\n- et bien d'autres choses encore !"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- option pour notifier les contacts supprimés.\n- noms de profil avec espaces.\n- et plus encore !"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- messages vocaux pouvant durer jusqu'à 5 minutes.\n- délai personnalisé de disparition.\n- l'historique de modification."; @@ -43,6 +46,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(nouveau)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(cet appareil v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -548,6 +557,9 @@ /* No comment provided by engineer. */ "Back" = "Retour"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Mauvaise adresse de bureau"; + /* integrity error chat item */ "bad message hash" = "hash de message incorrect"; @@ -560,12 +572,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "Mauvais ID de message"; +/* No comment provided by engineer. */ +"Better groups" = "Des groupes plus performants"; + /* No comment provided by engineer. */ "Better messages" = "Meilleurs messages"; /* No comment provided by engineer. */ "Block" = "Bloquer"; +/* No comment provided by engineer. */ +"Block group members" = "Bloquer des membres d'un groupe"; + /* No comment provided by engineer. */ "Block member" = "Bloquer ce membre"; @@ -771,6 +789,9 @@ /* No comment provided by engineer. */ "Connect incognito" = "Se connecter incognito"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Se connecter au bureau"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "se connecter aux developpeurs de SimpleX Chat."; @@ -801,9 +822,15 @@ /* No comment provided by engineer. */ "connected" = "connecté"; +/* No comment provided by engineer. */ +"Connected desktop" = "Bureau connecté"; + /* rcv group event chat item */ "connected directly" = "s'est connecté.e de manière directe"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Connecté au bureau"; + /* No comment provided by engineer. */ "connecting" = "connexion"; @@ -828,6 +855,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connexion au serveur… (erreur : %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Connexion au bureau"; + /* chat list item title */ "connecting…" = "connexion…"; @@ -846,6 +876,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Demande de connexion envoyée !"; +/* No comment provided by engineer. */ +"Connection terminated" = "Connexion terminée"; + /* No comment provided by engineer. */ "Connection timeout" = "Délai de connexion"; @@ -900,6 +933,9 @@ /* No comment provided by engineer. */ "Create" = "Créer"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Création de groupes via un profil aléatoire."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Créez une adresse pour permettre aux gens de vous contacter."; @@ -1173,6 +1209,15 @@ /* No comment provided by engineer. */ "Description" = "Description"; +/* No comment provided by engineer. */ +"Desktop address" = "Adresse de bureau"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "La version de l'application de bureau %@ n'est pas compatible avec cette application."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Appareils de bureau"; + /* No comment provided by engineer. */ "Develop" = "Développer"; @@ -1236,6 +1281,9 @@ /* server test step */ "Disconnect" = "Se déconnecter"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Déconnecter le bureau ?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Découvrir et rejoindre des groupes"; @@ -1374,6 +1422,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "renégociation de chiffrement autorisée pour %@"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Erreur lors de la renégociation du chiffrement"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "La renégociation du chiffrement a échoué."; + /* chat item text */ "encryption re-negotiation required" = "renégociation de chiffrement requise"; @@ -1404,6 +1458,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Entrer un serveur manuellement"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Entrez le nom de l'appareil…"; + /* placeholder */ "Enter welcome message…" = "Entrez un message de bienvenue…"; @@ -1605,6 +1662,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Rapide et ne nécessitant pas d'attendre que l'expéditeur soit en ligne !"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Connexion plus rapide et messages plus fiables."; + /* No comment provided by engineer. */ "Favorite" = "Favoris"; @@ -1866,6 +1926,9 @@ /* No comment provided by engineer. */ "Incognito" = "Incognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Groupes incognito"; + /* No comment provided by engineer. */ "Incognito mode" = "Mode Incognito"; @@ -1893,6 +1956,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Version de la base de données incompatible"; +/* No comment provided by engineer. */ +"Incompatible version" = "Version incompatible"; + /* PIN entry */ "Incorrect passcode" = "Code d'accès erroné"; @@ -2028,6 +2094,9 @@ /* No comment provided by engineer. */ "Joining group" = "Entrain de rejoindre le groupe"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Garder l'application ouverte pour l'utiliser depuis le bureau"; + /* No comment provided by engineer. */ "Keep your connections" = "Conserver vos connexions"; @@ -2064,6 +2133,15 @@ /* No comment provided by engineer. */ "Limitations" = "Limitations"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Liez vos applications mobiles et de bureau ! 🔗"; + +/* No comment provided by engineer. */ +"Linked desktop options" = "Options de bureau lié"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Bureaux liés"; + /* No comment provided by engineer. */ "LIVE" = "LIVE"; @@ -2462,6 +2540,9 @@ /* No comment provided by engineer. */ "Paste" = "Coller"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Coller l'adresse du bureau"; + /* No comment provided by engineer. */ "Paste image" = "Coller l'image"; @@ -2846,6 +2927,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "Scanner un code QR"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Scanner le code QR du bureau"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Scannez le code de sécurité depuis l'application de votre contact."; @@ -2990,6 +3074,9 @@ /* No comment provided by engineer. */ "Servers" = "Serveurs"; +/* No comment provided by engineer. */ +"Session code" = "Code de session"; + /* No comment provided by engineer. */ "Set 1 day" = "Définir 1 jour"; @@ -3299,6 +3386,9 @@ /* notification title */ "this contact" = "ce contact"; +/* No comment provided by engineer. */ +"This device name" = "Ce nom d'appareil"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Ce groupe compte plus de %lld membres, les accusés de réception ne sont pas envoyés."; @@ -3320,6 +3410,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Pour se connecter, votre contact peut scanner le code QR ou utiliser le lien dans l'application."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Pour cacher les messages indésirables."; + /* No comment provided by engineer. */ "To make a new connection" = "Pour établir une nouvelle connexion"; @@ -3416,6 +3509,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "A moins que votre contact ait supprimé la connexion ou que ce lien ait déjà été utilisé, il peut s'agir d'un bug - veuillez le signaler.\nPour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d'une connexion réseau stable."; +/* No comment provided by engineer. */ +"Unlink" = "Délier"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Délier le bureau ?"; + /* No comment provided by engineer. */ "Unlock" = "Déverrouiller"; @@ -3470,6 +3569,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Utiliser pour les nouvelles connexions"; +/* No comment provided by engineer. */ +"Use from desktop" = "Utilisation depuis le bureau"; + /* No comment provided by engineer. */ "Use iOS call interface" = "Utiliser l'interface d'appel d'iOS"; @@ -3491,12 +3593,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "Utilisation des serveurs SimpleX Chat."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Vérifier le code avec le bureau"; + +/* No comment provided by engineer. */ +"Verify connection" = "Vérifier la connexion"; + /* No comment provided by engineer. */ "Verify connection security" = "Vérifier la sécurité de la connexion"; +/* No comment provided by engineer. */ +"Verify connections" = "Vérifier les connexions"; + /* No comment provided by engineer. */ "Verify security code" = "Vérifier le code de sécurité"; @@ -3515,6 +3629,9 @@ /* No comment provided by engineer. */ "via relay" = "via relais"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Via un protocole sécurisé de cryptographie post-quantique."; + /* No comment provided by engineer. */ "Video call" = "Appel vidéo"; diff --git a/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings index f04cc09b00..e5bfdf09df 100644 --- a/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleGroup not found!X utilise Face ID pour l'authentification locale"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX utilise un accès au réseau local pour permettre l'utilisation du profil de chat de l'utilisateur via l'application de bureau au sein de ce même réseau."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX a besoin d'un accès au microphone pour les appels audio et vidéo ainsi que pour enregistrer des messages vocaux."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index eb77c2fe1e..274b909da2 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- recapito dei messaggi più stabile.\n- gruppi un po' migliorati.\n- e altro ancora!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- avvisa facoltativamente i contatti eliminati.\n- nomi del profilo con spazi.\n- e molto altro!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- messaggi vocali fino a 5 minuti.\n- tempo di scomparsa personalizzato.\n- cronologia delle modifiche."; @@ -569,12 +572,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "ID del messaggio errato"; +/* No comment provided by engineer. */ +"Better groups" = "Gruppi migliorati"; + /* No comment provided by engineer. */ "Better messages" = "Messaggi migliorati"; /* No comment provided by engineer. */ "Block" = "Blocca"; +/* No comment provided by engineer. */ +"Block group members" = "Blocca i membri dei gruppi"; + /* No comment provided by engineer. */ "Block member" = "Blocca membro"; @@ -924,6 +933,9 @@ /* No comment provided by engineer. */ "Create" = "Crea"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Crea un gruppo usando un profilo casuale."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Crea un indirizzo per consentire alle persone di connettersi con te."; @@ -1275,9 +1287,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Scopri ed unisciti ai gruppi"; -/* No comment provided by engineer. */ -"Discover on network" = "Trova nella rete"; - /* No comment provided by engineer. */ "Do it later" = "Fallo dopo"; @@ -1653,6 +1662,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Veloce e senza aspettare che il mittente sia in linea!"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Ingresso più veloce e messaggi più affidabili."; + /* No comment provided by engineer. */ "Favorite" = "Preferito"; @@ -1914,6 +1926,9 @@ /* No comment provided by engineer. */ "Incognito" = "Incognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Gruppi in incognito"; + /* No comment provided by engineer. */ "Incognito mode" = "Modalità incognito"; @@ -2118,6 +2133,9 @@ /* No comment provided by engineer. */ "Limitations" = "Limitazioni"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Collega le app mobile e desktop! 🔗"; + /* No comment provided by engineer. */ "Linked desktop options" = "Opzioni del desktop collegato"; @@ -3392,6 +3410,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Per connettervi, il tuo contatto può scansionare il codice QR o usare il link nell'app."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Per nascondere messaggi indesiderati."; + /* No comment provided by engineer. */ "To make a new connection" = "Per creare una nuova connessione"; @@ -3608,6 +3629,9 @@ /* No comment provided by engineer. */ "via relay" = "via relay"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Tramite protocollo sicuro resistente alla quantistica."; + /* No comment provided by engineer. */ "Video call" = "Videochiamata"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 4366d6cb93..ac1f8b03d3 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabielere berichtbezorging.\n- een beetje betere groepen.\n- en meer!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- optioneel verwijderde contacten op de hoogte stellen.\n- profielnamen met spaties.\n- en meer!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- spraakberichten tot 5 minuten.\n- aangepaste tijd om te verdwijnen.\n- bewerkingsgeschiedenis."; @@ -569,12 +572,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "Onjuiste bericht-ID"; +/* No comment provided by engineer. */ +"Better groups" = "Betere groepen"; + /* No comment provided by engineer. */ "Better messages" = "Betere berichten"; /* No comment provided by engineer. */ "Block" = "Blokkeren"; +/* No comment provided by engineer. */ +"Block group members" = "Groepsleden blokkeren"; + /* No comment provided by engineer. */ "Block member" = "Lid blokkeren"; @@ -924,6 +933,9 @@ /* No comment provided by engineer. */ "Create" = "Maak"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Maak een groep met een willekeurig profiel."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Maak een adres aan zodat mensen contact met je kunnen opnemen."; @@ -1275,9 +1287,6 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Ontdek en sluit je aan bij groepen"; -/* No comment provided by engineer. */ -"Discover on network" = "Ontdek via netwerk"; - /* No comment provided by engineer. */ "Do it later" = "Doe het later"; @@ -1653,6 +1662,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Snel en niet wachten tot de afzender online is!"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Snellere deelname en betrouwbaardere berichten."; + /* No comment provided by engineer. */ "Favorite" = "Favoriet"; @@ -1914,6 +1926,9 @@ /* No comment provided by engineer. */ "Incognito" = "Incognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Incognitogroepen"; + /* No comment provided by engineer. */ "Incognito mode" = "Incognito modus"; @@ -2118,6 +2133,9 @@ /* No comment provided by engineer. */ "Limitations" = "Beperkingen"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Koppel mobiele en desktop-apps! 🔗"; + /* No comment provided by engineer. */ "Linked desktop options" = "Gekoppelde desktop opties"; @@ -3392,6 +3410,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Om verbinding te maken, kan uw contact de QR-code scannen of de link in de app gebruiken."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Om ongewenste berichten te verbergen."; + /* No comment provided by engineer. */ "To make a new connection" = "Om een nieuwe verbinding te maken"; @@ -3608,6 +3629,9 @@ /* No comment provided by engineer. */ "via relay" = "via relais"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Via een beveiligd kwantumbestendig protocol."; + /* No comment provided by engineer. */ "Video call" = "video oproep"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 73f06afeee..7d094fcf20 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- более стабильная доставка сообщений.\n- немного улучшенные группы.\n- и прочее!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- опционально уведомляйте удалённые контакты.\n- имена профилей с пробелами.\n- и прочее!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- голосовые сообщения до 5 минут.\n- настройка времени исчезающих сообщений.\n- история редактирования."; @@ -43,6 +46,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(новое)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(это устройство v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -118,12 +127,18 @@ /* No comment provided by engineer. */ "%@ %@" = "%@ %@"; +/* No comment provided by engineer. */ +"%@ and %@" = "%@ и %@"; + /* No comment provided by engineer. */ "%@ and %@ connected" = "%@ и %@ соединены"; /* copied message info, at Connect automatically + Automatisch verbinden No comment provided by engineer. @@ -1914,6 +1915,7 @@ Das kann nicht rückgängig gemacht werden! Discover via local network + Lokales Netzwerk durchsuchen No comment provided by engineer. @@ -2558,6 +2560,7 @@ Das kann nicht rückgängig gemacht werden! Found desktop + Gefundener Desktop No comment provided by engineer. @@ -3527,6 +3530,7 @@ Das ist Ihr Link für die Gruppe %@! Not compatible! + Nicht kompatibel! No comment provided by engineer. @@ -5501,6 +5505,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Waiting for desktop... + Es wird auf den Desktop gewartet... No comment provided by engineer. @@ -6050,6 +6055,7 @@ SimpleX-Server können Ihr Profil nicht einsehen. author + Autor member role diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index af54b7ceb9..153d98be3c 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -1196,6 +1196,7 @@ Connect automatically + Connexion automatique No comment provided by engineer. @@ -1914,6 +1915,7 @@ Cette opération ne peut être annulée ! Discover via local network + Rechercher sur le réseau No comment provided by engineer. @@ -2558,6 +2560,7 @@ Cette opération ne peut être annulée ! Found desktop + Bureau trouvé No comment provided by engineer. @@ -3527,6 +3530,7 @@ Voici votre lien pour le groupe %@ ! Not compatible! + Non compatible ! No comment provided by engineer. @@ -5501,6 +5505,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Waiting for desktop... + En attente du bureau... No comment provided by engineer. @@ -6050,6 +6055,7 @@ Les serveurs SimpleX ne peuvent pas voir votre profil. author + auteur member role diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index d2be34ec6c..094a30677a 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -1196,6 +1196,7 @@ Connect automatically + Automatisch verbinden No comment provided by engineer. @@ -1914,6 +1915,7 @@ Dit kan niet ongedaan gemaakt worden! Discover via local network + Ontdek via het lokale netwerk No comment provided by engineer. @@ -2558,6 +2560,7 @@ Dit kan niet ongedaan gemaakt worden! Found desktop + Desktop gevonden No comment provided by engineer. @@ -3527,6 +3530,7 @@ Dit is jouw link voor groep %@! Not compatible! + Niet compatibel! No comment provided by engineer. @@ -5501,6 +5505,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Waiting for desktop... + Wachten op desktop... No comment provided by engineer. @@ -6050,6 +6055,7 @@ SimpleX servers kunnen uw profiel niet zien. author + auteur member role diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 61147d7f98..ad1924f4c4 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -299,10 +299,12 @@ (new) + (nowy) No comment provided by engineer. (this device v%@) + (to urządzenie v%@) No comment provided by engineer. @@ -397,6 +399,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - opcjonalnie powiadamiaj usunięte kontakty. +- nazwy profili ze spacją. +- i wiele więcej! No comment provided by engineer. @@ -885,6 +890,7 @@ Bad desktop address + Zły adres komputera No comment provided by engineer. @@ -899,6 +905,7 @@ Better groups + Lepsze grupy No comment provided by engineer. @@ -913,6 +920,7 @@ Block group members + Blokuj członków grupy No comment provided by engineer. @@ -1188,6 +1196,7 @@ Connect automatically + Łącz automatycznie No comment provided by engineer. @@ -1197,6 +1206,7 @@ Connect to desktop + Połącz do komputera No comment provided by engineer. @@ -1245,10 +1255,12 @@ To jest twój jednorazowy link! Connected desktop + Połączony komputer No comment provided by engineer. Connected to desktop + Połączony do komputera No comment provided by engineer. @@ -1263,6 +1275,7 @@ To jest twój jednorazowy link! Connecting to desktop + Łączenie z komputerem No comment provided by engineer. @@ -1287,6 +1300,7 @@ To jest twój jednorazowy link! Connection terminated + Połączenie zakończone No comment provided by engineer. @@ -1371,6 +1385,7 @@ To jest twój jednorazowy link! Create a group using a random profile. + Utwórz grupę używając losowego profilu. No comment provided by engineer. @@ -1785,14 +1800,17 @@ To nie może być cofnięte! Desktop address + Adres komputera No comment provided by engineer. Desktop app version %@ is not compatible with this app. + Wersja aplikacji komputerowej %@ nie jest kompatybilna z tą aplikacją. No comment provided by engineer. Desktop devices + Urządzenia komputerowe No comment provided by engineer. @@ -1887,6 +1905,7 @@ To nie może być cofnięte! Disconnect desktop? + Rozłączyć komputer? No comment provided by engineer. @@ -1896,6 +1915,7 @@ To nie może być cofnięte! Discover via local network + Odkryj przez sieć lokalną No comment provided by engineer. @@ -2070,10 +2090,12 @@ To nie może być cofnięte! Encryption re-negotiation error + Błąd renegocjacji szyfrowania message decrypt error item Encryption re-negotiation failed. + Renegocjacja szyfrowania nie powiodła się. No comment provided by engineer. @@ -2108,6 +2130,7 @@ To nie może być cofnięte! Enter this device name… + Podaj nazwę urządzenia… No comment provided by engineer. @@ -2437,6 +2460,7 @@ To nie może być cofnięte! Faster joining and more reliable messages. + Szybsze dołączenie i bardziej niezawodne wiadomości. No comment provided by engineer. @@ -2536,6 +2560,7 @@ To nie może być cofnięte! Found desktop + Znaleziono komputer No comment provided by engineer. @@ -2865,6 +2890,7 @@ To nie może być cofnięte! Incognito groups + Grupy incognito No comment provided by engineer. @@ -2899,6 +2925,7 @@ To nie może być cofnięte! Incompatible version + Niekompatybilna wersja No comment provided by engineer. @@ -3073,6 +3100,7 @@ To jest twój link do grupy %@! Keep the app open to use it from desktop + Zostaw aplikację otwartą i używaj ją z komputera No comment provided by engineer. @@ -3137,14 +3165,17 @@ To jest twój link do grupy %@! Link mobile and desktop apps! 🔗 + Połącz mobile i komputerowe aplikacje! 🔗 No comment provided by engineer. Linked desktop options + Połączone opcje komputera No comment provided by engineer. Linked desktops + Połączone komputery No comment provided by engineer. @@ -3499,6 +3530,7 @@ To jest twój link do grupy %@! Not compatible! + Nie kompatybilny! No comment provided by engineer. @@ -3722,6 +3754,7 @@ To jest twój link do grupy %@! Paste desktop address + Wklej adres komputera No comment provided by engineer. @@ -4321,6 +4354,7 @@ To jest twój link do grupy %@! Scan QR code from desktop + Zeskanuj kod QR z komputera No comment provided by engineer. @@ -4545,6 +4579,7 @@ To jest twój link do grupy %@! Session code + Kod sesji No comment provided by engineer. @@ -5046,6 +5081,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This device name + Nazwa tego urządzenia No comment provided by engineer. @@ -5085,6 +5121,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom To hide unwanted messages. + Aby ukryć niechciane wiadomości. No comment provided by engineer. @@ -5248,10 +5285,12 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Unlink + Odłącz No comment provided by engineer. Unlink desktop? + Odłączyć komputer? No comment provided by engineer. @@ -5346,6 +5385,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use from desktop + Użyj z komputera No comment provided by engineer. @@ -5380,10 +5420,12 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Verify code with desktop + Zweryfikuj kod z komputera No comment provided by engineer. Verify connection + Zweryfikuj połączenie No comment provided by engineer. @@ -5393,6 +5435,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Verify connections + Zweryfikuj połączenia No comment provided by engineer. @@ -5407,6 +5450,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Via secure quantum resistant protocol. + Dzięki bezpiecznemu protokołowi odpornego kwantowo. No comment provided by engineer. @@ -5461,6 +5505,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Waiting for desktop... + Oczekiwanie na komputer... No comment provided by engineer. @@ -6010,6 +6055,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. author + autor member role @@ -6596,6 +6642,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. v%@ + v%@ No comment provided by engineer. @@ -6737,6 +6784,7 @@ Serwery SimpleX nie mogą zobaczyć Twojego profilu. SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + SimpleX używa sieci lokalnej aby pozwolić na dostęp profilom czatu użytkownika przez aplikację komputerową na tej samej sieci. Privacy - Local Network Usage Description diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index abd3d01d87..f4970446ae 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1196,6 +1196,7 @@ Connect automatically + Соединяться автоматически No comment provided by engineer. @@ -1914,6 +1915,7 @@ This cannot be undone! Discover via local network + Обнаружение по локальной сети No comment provided by engineer. @@ -2558,6 +2560,7 @@ This cannot be undone! Found desktop + Компьютер найден No comment provided by engineer. @@ -3527,6 +3530,7 @@ This is your link for group %@! Not compatible! + Несовместимая версия! No comment provided by engineer. @@ -5501,6 +5505,7 @@ To connect, please ask your contact to create another connection link and check Waiting for desktop... + Ожидается подключение компьютера... No comment provided by engineer. @@ -6050,6 +6055,7 @@ SimpleX серверы не могут получить доступ к Ваше author + автор member role diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index c860a24998..febd4c06a5 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -545,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Authentifizierung nicht verfügbar"; +/* member role */ +"author" = "Autor"; + /* No comment provided by engineer. */ "Auto-accept" = "Automatisch akzeptieren"; @@ -786,6 +789,9 @@ /* server test step */ "Connect" = "Verbinden"; +/* No comment provided by engineer. */ +"Connect automatically" = "Automatisch verbinden"; + /* No comment provided by engineer. */ "Connect incognito" = "Inkognito verbinden"; @@ -1287,6 +1293,9 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Gruppen entdecken und ihnen beitreten"; +/* No comment provided by engineer. */ +"Discover via local network" = "Lokales Netzwerk durchsuchen"; + /* No comment provided by engineer. */ "Do it later" = "Später wiederholen"; @@ -1722,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Für Konsole"; +/* No comment provided by engineer. */ +"Found desktop" = "Gefundener Desktop"; + /* No comment provided by engineer. */ "French interface" = "Französische Bedienoberfläche"; @@ -2397,6 +2409,9 @@ /* copied message info in history */ "no text" = "Kein Text"; +/* No comment provided by engineer. */ +"Not compatible!" = "Nicht kompatibel!"; + /* No comment provided by engineer. */ "Notifications" = "Benachrichtigungen"; @@ -3671,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "Warten auf Bestätigung…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "Es wird auf den Desktop gewartet..."; + /* No comment provided by engineer. */ "Waiting for file" = "Warte auf Datei"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index b71d510e72..470e23b4fc 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -545,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Authentification indisponible"; +/* member role */ +"author" = "auteur"; + /* No comment provided by engineer. */ "Auto-accept" = "Auto-accepter"; @@ -786,6 +789,9 @@ /* server test step */ "Connect" = "Se connecter"; +/* No comment provided by engineer. */ +"Connect automatically" = "Connexion automatique"; + /* No comment provided by engineer. */ "Connect incognito" = "Se connecter incognito"; @@ -1287,6 +1293,9 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Découvrir et rejoindre des groupes"; +/* No comment provided by engineer. */ +"Discover via local network" = "Rechercher sur le réseau"; + /* No comment provided by engineer. */ "Do it later" = "Faites-le plus tard"; @@ -1722,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Pour la console"; +/* No comment provided by engineer. */ +"Found desktop" = "Bureau trouvé"; + /* No comment provided by engineer. */ "French interface" = "Interface en français"; @@ -2397,6 +2409,9 @@ /* copied message info in history */ "no text" = "aucun texte"; +/* No comment provided by engineer. */ +"Not compatible!" = "Non compatible !"; + /* No comment provided by engineer. */ "Notifications" = "Notifications"; @@ -3671,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "en attente de confirmation…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "En attente du bureau..."; + /* No comment provided by engineer. */ "Waiting for file" = "En attente du fichier"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index ac1f8b03d3..784acf1a9b 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -545,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Verificatie niet beschikbaar"; +/* member role */ +"author" = "auteur"; + /* No comment provided by engineer. */ "Auto-accept" = "Automatisch accepteren"; @@ -786,6 +789,9 @@ /* server test step */ "Connect" = "Verbind"; +/* No comment provided by engineer. */ +"Connect automatically" = "Automatisch verbinden"; + /* No comment provided by engineer. */ "Connect incognito" = "Verbind incognito"; @@ -1287,6 +1293,9 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Ontdek en sluit je aan bij groepen"; +/* No comment provided by engineer. */ +"Discover via local network" = "Ontdek via het lokale netwerk"; + /* No comment provided by engineer. */ "Do it later" = "Doe het later"; @@ -1722,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Voor console"; +/* No comment provided by engineer. */ +"Found desktop" = "Desktop gevonden"; + /* No comment provided by engineer. */ "French interface" = "Franse interface"; @@ -2397,6 +2409,9 @@ /* copied message info in history */ "no text" = "geen tekst"; +/* No comment provided by engineer. */ +"Not compatible!" = "Niet compatibel!"; + /* No comment provided by engineer. */ "Notifications" = "Meldingen"; @@ -3671,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "Wachten op bevestiging…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "Wachten op desktop..."; + /* No comment provided by engineer. */ "Waiting for file" = "Wachten op bestand"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 3eb86d2a12..e0f4c4dee0 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- bardziej stabilne dostarczanie wiadomości.\n- nieco lepsze grupy.\n- i więcej!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- opcjonalnie powiadamiaj usunięte kontakty.\n- nazwy profili ze spacją.\n- i wiele więcej!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- wiadomości głosowe do 5 minut.\n- niestandardowy czas zniknięcia.\n- historia edycji."; @@ -43,6 +46,12 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(new)" = "(nowy)"; + +/* No comment provided by engineer. */ +"(this device v%@)" = "(to urządzenie v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -536,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Uwierzytelnianie niedostępne"; +/* member role */ +"author" = "autor"; + /* No comment provided by engineer. */ "Auto-accept" = "Automatycznie akceptuj"; @@ -548,6 +560,9 @@ /* No comment provided by engineer. */ "Back" = "Wstecz"; +/* No comment provided by engineer. */ +"Bad desktop address" = "Zły adres komputera"; + /* integrity error chat item */ "bad message hash" = "zły hash wiadomości"; @@ -560,12 +575,18 @@ /* No comment provided by engineer. */ "Bad message ID" = "Zły identyfikator wiadomości"; +/* No comment provided by engineer. */ +"Better groups" = "Lepsze grupy"; + /* No comment provided by engineer. */ "Better messages" = "Lepsze wiadomości"; /* No comment provided by engineer. */ "Block" = "Zablokuj"; +/* No comment provided by engineer. */ +"Block group members" = "Blokuj członków grupy"; + /* No comment provided by engineer. */ "Block member" = "Zablokuj członka"; @@ -768,9 +789,15 @@ /* server test step */ "Connect" = "Połącz"; +/* No comment provided by engineer. */ +"Connect automatically" = "Łącz automatycznie"; + /* No comment provided by engineer. */ "Connect incognito" = "Połącz incognito"; +/* No comment provided by engineer. */ +"Connect to desktop" = "Połącz do komputera"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "połącz się z deweloperami SimpleX Chat."; @@ -801,9 +828,15 @@ /* No comment provided by engineer. */ "connected" = "połączony"; +/* No comment provided by engineer. */ +"Connected desktop" = "Połączony komputer"; + /* rcv group event chat item */ "connected directly" = "połącz bezpośrednio"; +/* No comment provided by engineer. */ +"Connected to desktop" = "Połączony do komputera"; + /* No comment provided by engineer. */ "connecting" = "łączenie"; @@ -828,6 +861,9 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Łączenie z serwerem... (błąd: %@)"; +/* No comment provided by engineer. */ +"Connecting to desktop" = "Łączenie z komputerem"; + /* chat list item title */ "connecting…" = "łączenie…"; @@ -846,6 +882,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Prośba o połączenie wysłana!"; +/* No comment provided by engineer. */ +"Connection terminated" = "Połączenie zakończone"; + /* No comment provided by engineer. */ "Connection timeout" = "Czas połączenia minął"; @@ -900,6 +939,9 @@ /* No comment provided by engineer. */ "Create" = "Utwórz"; +/* No comment provided by engineer. */ +"Create a group using a random profile." = "Utwórz grupę używając losowego profilu."; + /* No comment provided by engineer. */ "Create an address to let people connect with you." = "Utwórz adres, aby ludzie mogli się z Tobą połączyć."; @@ -1173,6 +1215,15 @@ /* No comment provided by engineer. */ "Description" = "Opis"; +/* No comment provided by engineer. */ +"Desktop address" = "Adres komputera"; + +/* No comment provided by engineer. */ +"Desktop app version %@ is not compatible with this app." = "Wersja aplikacji komputerowej %@ nie jest kompatybilna z tą aplikacją."; + +/* No comment provided by engineer. */ +"Desktop devices" = "Urządzenia komputerowe"; + /* No comment provided by engineer. */ "Develop" = "Deweloperskie"; @@ -1236,9 +1287,15 @@ /* server test step */ "Disconnect" = "Rozłącz"; +/* No comment provided by engineer. */ +"Disconnect desktop?" = "Rozłączyć komputer?"; + /* No comment provided by engineer. */ "Discover and join groups" = "Odkrywaj i dołączaj do grup"; +/* No comment provided by engineer. */ +"Discover via local network" = "Odkryj przez sieć lokalną"; + /* No comment provided by engineer. */ "Do it later" = "Zrób to później"; @@ -1374,6 +1431,12 @@ /* chat item text */ "encryption re-negotiation allowed for %@" = "renegocjacja szyfrowania dozwolona dla %@"; +/* message decrypt error item */ +"Encryption re-negotiation error" = "Błąd renegocjacji szyfrowania"; + +/* No comment provided by engineer. */ +"Encryption re-negotiation failed." = "Renegocjacja szyfrowania nie powiodła się."; + /* chat item text */ "encryption re-negotiation required" = "renegocjacja szyfrowania wymagana"; @@ -1404,6 +1467,9 @@ /* No comment provided by engineer. */ "Enter server manually" = "Wprowadź serwer ręcznie"; +/* No comment provided by engineer. */ +"Enter this device name…" = "Podaj nazwę urządzenia…"; + /* placeholder */ "Enter welcome message…" = "Wpisz wiadomość powitalną…"; @@ -1605,6 +1671,9 @@ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Szybko i bez czekania aż nadawca będzie online!"; +/* No comment provided by engineer. */ +"Faster joining and more reliable messages." = "Szybsze dołączenie i bardziej niezawodne wiadomości."; + /* No comment provided by engineer. */ "Favorite" = "Ulubione"; @@ -1662,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Dla konsoli"; +/* No comment provided by engineer. */ +"Found desktop" = "Znaleziono komputer"; + /* No comment provided by engineer. */ "French interface" = "Francuski interfejs"; @@ -1866,6 +1938,9 @@ /* No comment provided by engineer. */ "Incognito" = "Incognito"; +/* No comment provided by engineer. */ +"Incognito groups" = "Grupy incognito"; + /* No comment provided by engineer. */ "Incognito mode" = "Tryb incognito"; @@ -1893,6 +1968,9 @@ /* No comment provided by engineer. */ "Incompatible database version" = "Niekompatybilna wersja bazy danych"; +/* No comment provided by engineer. */ +"Incompatible version" = "Niekompatybilna wersja"; + /* PIN entry */ "Incorrect passcode" = "Nieprawidłowy pin"; @@ -2028,6 +2106,9 @@ /* No comment provided by engineer. */ "Joining group" = "Dołączanie do grupy"; +/* No comment provided by engineer. */ +"Keep the app open to use it from desktop" = "Zostaw aplikację otwartą i używaj ją z komputera"; + /* No comment provided by engineer. */ "Keep your connections" = "Zachowaj swoje połączenia"; @@ -2064,6 +2145,15 @@ /* No comment provided by engineer. */ "Limitations" = "Ograniczenia"; +/* No comment provided by engineer. */ +"Link mobile and desktop apps! 🔗" = "Połącz mobile i komputerowe aplikacje! 🔗"; + +/* No comment provided by engineer. */ +"Linked desktop options" = "Połączone opcje komputera"; + +/* No comment provided by engineer. */ +"Linked desktops" = "Połączone komputery"; + /* No comment provided by engineer. */ "LIVE" = "NA ŻYWO"; @@ -2319,6 +2409,9 @@ /* copied message info in history */ "no text" = "brak tekstu"; +/* No comment provided by engineer. */ +"Not compatible!" = "Nie kompatybilny!"; + /* No comment provided by engineer. */ "Notifications" = "Powiadomienia"; @@ -2462,6 +2555,9 @@ /* No comment provided by engineer. */ "Paste" = "Wklej"; +/* No comment provided by engineer. */ +"Paste desktop address" = "Wklej adres komputera"; + /* No comment provided by engineer. */ "Paste image" = "Wklej obraz"; @@ -2846,6 +2942,9 @@ /* No comment provided by engineer. */ "Scan QR code" = "Zeskanuj kod QR"; +/* No comment provided by engineer. */ +"Scan QR code from desktop" = "Zeskanuj kod QR z komputera"; + /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Zeskanuj kod bezpieczeństwa z aplikacji Twojego kontaktu."; @@ -2990,6 +3089,9 @@ /* No comment provided by engineer. */ "Servers" = "Serwery"; +/* No comment provided by engineer. */ +"Session code" = "Kod sesji"; + /* No comment provided by engineer. */ "Set 1 day" = "Ustaw 1 dzień"; @@ -3299,6 +3401,9 @@ /* notification title */ "this contact" = "ten kontakt"; +/* No comment provided by engineer. */ +"This device name" = "Nazwa tego urządzenia"; + /* No comment provided by engineer. */ "This group has over %lld members, delivery receipts are not sent." = "Ta grupa ma ponad %lld członków, potwierdzenia dostawy nie są wysyłane."; @@ -3320,6 +3425,9 @@ /* No comment provided by engineer. */ "To connect, your contact can scan QR code or use the link in the app." = "Aby się połączyć, Twój kontakt może zeskanować kod QR lub skorzystać z linku w aplikacji."; +/* No comment provided by engineer. */ +"To hide unwanted messages." = "Aby ukryć niechciane wiadomości."; + /* No comment provided by engineer. */ "To make a new connection" = "Aby nawiązać nowe połączenie"; @@ -3416,6 +3524,12 @@ /* No comment provided by engineer. */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "O ile Twój kontakt nie usunął połączenia lub ten link był już użyty, może to być błąd - zgłoś go.\nAby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połączenia i sprawdź, czy masz stabilne połączenie z siecią."; +/* No comment provided by engineer. */ +"Unlink" = "Odłącz"; + +/* No comment provided by engineer. */ +"Unlink desktop?" = "Odłączyć komputer?"; + /* No comment provided by engineer. */ "Unlock" = "Odblokuj"; @@ -3470,6 +3584,9 @@ /* No comment provided by engineer. */ "Use for new connections" = "Użyj dla nowych połączeń"; +/* No comment provided by engineer. */ +"Use from desktop" = "Użyj z komputera"; + /* No comment provided by engineer. */ "Use iOS call interface" = "Użyj interfejsu połączeń iOS"; @@ -3491,12 +3608,24 @@ /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "Używanie serwerów SimpleX Chat."; +/* No comment provided by engineer. */ +"v%@" = "v%@"; + /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* No comment provided by engineer. */ +"Verify code with desktop" = "Zweryfikuj kod z komputera"; + +/* No comment provided by engineer. */ +"Verify connection" = "Zweryfikuj połączenie"; + /* No comment provided by engineer. */ "Verify connection security" = "Weryfikuj bezpieczeństwo połączenia"; +/* No comment provided by engineer. */ +"Verify connections" = "Zweryfikuj połączenia"; + /* No comment provided by engineer. */ "Verify security code" = "Weryfikuj kod bezpieczeństwa"; @@ -3515,6 +3644,9 @@ /* No comment provided by engineer. */ "via relay" = "przez przekaźnik"; +/* No comment provided by engineer. */ +"Via secure quantum resistant protocol." = "Dzięki bezpiecznemu protokołowi odpornego kwantowo."; + /* No comment provided by engineer. */ "Video call" = "Połączenie wideo"; @@ -3554,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "oczekiwanie na potwierdzenie…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "Oczekiwanie na komputer..."; + /* No comment provided by engineer. */ "Waiting for file" = "Oczekiwanie na plik"; diff --git a/apps/ios/pl.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/pl.lproj/SimpleX--iOS--InfoPlist.strings index 92f6ba7764..8c0f71f744 100644 --- a/apps/ios/pl.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/pl.lproj/SimpleX--iOS--InfoPlist.strings @@ -7,6 +7,9 @@ /* Privacy - Face ID Usage Description */ "NSFaceIDUsageDescription" = "SimpleX używa Face ID do lokalnego uwierzytelniania"; +/* Privacy - Local Network Usage Description */ +"NSLocalNetworkUsageDescription" = "SimpleX używa sieci lokalnej aby pozwolić na dostęp profilom czatu użytkownika przez aplikację komputerową na tej samej sieci."; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX potrzebuje dostępu do mikrofonu, w celu połączeń audio i wideo oraz nagrywania wiadomości głosowych."; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 7d094fcf20..03e4c4c508 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -545,6 +545,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Аутентификация недоступна"; +/* member role */ +"author" = "автор"; + /* No comment provided by engineer. */ "Auto-accept" = "Автоприем"; @@ -786,6 +789,9 @@ /* server test step */ "Connect" = "Соединиться"; +/* No comment provided by engineer. */ +"Connect automatically" = "Соединяться автоматически"; + /* No comment provided by engineer. */ "Connect incognito" = "Соединиться Инкогнито"; @@ -1287,6 +1293,9 @@ /* No comment provided by engineer. */ "Discover and join groups" = "Найдите и вступите в группы"; +/* No comment provided by engineer. */ +"Discover via local network" = "Обнаружение по локальной сети"; + /* No comment provided by engineer. */ "Do it later" = "Отложить"; @@ -1722,6 +1731,9 @@ /* No comment provided by engineer. */ "For console" = "Для консоли"; +/* No comment provided by engineer. */ +"Found desktop" = "Компьютер найден"; + /* No comment provided by engineer. */ "French interface" = "Французский интерфейс"; @@ -2397,6 +2409,9 @@ /* copied message info in history */ "no text" = "нет текста"; +/* No comment provided by engineer. */ +"Not compatible!" = "Несовместимая версия!"; + /* No comment provided by engineer. */ "Notifications" = "Уведомления"; @@ -3671,6 +3686,9 @@ /* No comment provided by engineer. */ "waiting for confirmation…" = "ожидается подтверждение…"; +/* No comment provided by engineer. */ +"Waiting for desktop..." = "Ожидается подключение компьютера..."; + /* No comment provided by engineer. */ "Waiting for file" = "Ожидается прием файла"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 82217745f7..a9d315f680 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1519,4 +1519,9 @@ Veuillez patienter le temps que le fichier soit chargé depuis le mobile lié. La version de l\'application de bureau %s n\'est pas compatible avec cette application. Vérifier la connexion + Connexion automatique + En attente du bureau… + Bureau trouvé + Non compatible ! + Accessible via le réseau local \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index eb28386e5d..21a56d7e99 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -356,4 +356,116 @@ Adatbázis downgrade? Chat kiürítése Adatbázis titkosítási jelmondat meg lesz változtatva. + Kapcsolódás automatikusan + Adatbázis hiba + Adatbázis titkosítási jelmondat frissül és eltárolásra kerül a beállításokban. + Adatbázis ID + Adatbázis ID: %d + Adatbázis azonosítók és \"Transport Isolation\" opciók. + Az adatbázis titkosítás jelmondata megváltoztatásra és elmentésre kerül a Keystore-ban. + Az adatbázis titkosításra kerül és a jelmondat eltárolásra a beállításokban. + Szerver törlése + Eszközhitelesítés kikapcsolva. SimpleX zár kikapcsolása. + Letiltás + Letiltás minden csoportnak + Engedélyezve minden csoportnak + engedélyezve az ismerősnek + Eltűnő üzenetek tiltottak ebben a csoportban. + Azonosító törlés + %d hét + PC címe + %ds + Kézbesítési izagolások! + Eszközhitelesítés nincs bekapcsolva. Bekapcsolhatod a SimpleX zárat a Beállításokon keresztük, miután bekapcsoltad az eszközhitelesítést. + Titkosítás visszafejtési hiba + Eltűnik ekkor: %s + szerkesztve + Törlés + %d óra + %d hónap + Azonosító törlése? + Igazolások letiltása? + Az adatbázis jelmondat eltérő a Keystore-ba elmentettől. + Közvetlen üzenetek + E-mail + Letiltás mindenkinek + Fejlesztői eszközök + Adatbázis jelmondat + %d napok + Szétkapcsolva + Az adatbázis egy véletlenszerű jelmondattal van titkosítva, lecserélheted. + %dó + %dhét + Felfedezés helyi hálózatomn keresztül + Helyi csoportok felfedezése és csatolakozás + %d üzenet moderálva %s által + Eltűnő üzenet + Ne hozz létre azonosítót + Ne mutasd ismét + SimpleX Zár kikapcsolása + e2e titkosított + ESZKÖZ + e2e titkosított videóhívás + közvetlen + PC + %d perc + %d ismerős(-ök) kiválasztva + Engedélyez + %dhónap + A közvetlen üzenetek tagok között titltottak ebben a csoportban. + %d perc + Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Kérlek cseréld le exportálás előtt! + Igazolások letiltása csoportoknak? + nap + %d nap + Chat archív törlése? + Duplikálódott megjelenítési név! + Letiltás(felülírások megtartásával) + "Adatbázis upgrade" + %d üzenet blokkolva + Eltűnik ekkor + %d hét + engedélyezve számodra + Eltűnő üzenetek + Törlés + Törlés és ismerős értesítése + letiltva + %d másodperc + Minden fájl törlése + Az adatbázis titkosításra kerül. + Adatbázis jelmondat és exportálás + Az adatbázis titkosításra kerül és a jelmondat eltárolásra a Keystore-ban. + Automatikus üzenet törlés engedélyezve? + Törlés + az adatbázis verzió újabb, mint az app, de nincs lefelé migráció eddig: %s + Leírás + %d óra + %dp + Szétkapcsolás + Szerkesztés + Letiltás(csoport felülírások megtartásával) + %d csoportesemény + %d hónap + Csoport profil szerkesztése + e2e titkosított hanghívás + %d s + Decentralizált + Dekódolási hiba + Kép szerkesztése + Értesítéásek letiltása + Eszközök + Látható helyi hálózaton + Ne engedélyezd + Archívum törlése + Eltűnő üzenetek tiltottak ebben a chatben. + alap (%s) + duplikálódott üzenet + Számítógép leválasztása? + A számítógépes app verzió %s inem kompatibilis ezzel az appal. + Kézbesítés + %d fájl %s összméretben + Adatbázis jelmondat szükséges chat megnyitásához. + %dn + Engedélyeve mindenki számára + Kézbesítési izagolások kikapcsolva! \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index e61f040a89..a9549080a8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1461,4 +1461,67 @@ Pokaż konsolę w nowym oknie Błąd renegocjacji szyfrowania Renegocjacja szyfrowania nie powiodła się. + Blokuj członków grupy + Utwórz grupę używając losowego profilu. + Połączony komputer + Łącz automatycznie + Adres komputera + Lepsze grupy + Komputer + Połączony do komputera + Łączenie z komputerem + Połączony telefon + Połączenie zakończone + Połącz do komputera + autor + Połączony do telefonu + Zły adres komputera + Aplikacja komputerowa %s nie jest kompatybilna z tą aplikacją. + Nowy urządzenie mobilne + Tylko jedno urządzenie może działać w tym samym czasie + Połącz mobile i komputerowe aplikacje! 🔗 + Dzięki bezpiecznemu protokołowi odpornego kwantowo. + Użyj z komputera w aplikacji mobilnej i zeskanuj kod QR.]]> + Aby ukryć niechciane wiadomości. + Niekompatybilna wersja + (nowy)]]> + Odłączyć komputer? + Połączone opcje komputera + Połączone komputery + Odkryj przez sieć lokalną + Grupy incognito + To urządzenie + %s został rozłączony]]> + Oczekiwanie na komputer… + Szybsze dołączenie i bardziej niezawodne wiadomości. + Połączone telefony + Nazwa tego urządzenia + %s]]> + Ładowanie pliku + Znaleziono komputer + Urządzenia komputerowe + Nie kompatybilny! + Połącz z telefonem + Użyj z komputera + Kod sesji + (to urządzenie v%s)]]> + Odłącz + Nazwa urządzenia zostanie udostępniona z połączonym klientem mobilnym. + Zweryfikuj kod na telefonie + Podaj nazwę urządzenia… + Błąd + Rozłącz + Wklej adres komputera + Zweryfikuj kod z komputera + Zeskanuj kod QR z komputera + Urządzenia + Odkrywane przez sieć lokalna + - opcjonalnie powiadamiaj usunięte kontakty. +\n- nazwy profili ze spacją. +\n- i wiele więcej! + Zeskanuj z telefonu + Zweryfikuj połączenia + Rozłączyć komputer? + Proszę poczekać na załadowanie pliku z połączonego telefonu + Zweryfikuj połączenie \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 91a37196ff..dc5771316c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1507,7 +1507,7 @@ Свяжите мобильное и настольное приложения! 🔗 %d сообщений помечено удалёнными Группа уже существует! - Использовать с компьютера в мобильном приложении и сосканируйте QR код]]> + Использовать с компьютера в мобильном приложении и сосканируйте QR код.]]> Уже соединяется! Несовместимая версия (новое)]]> @@ -1520,7 +1520,7 @@ Инкогнито группы Вступление в группу уже начато! %d сообщений модерировано членом %s - %s]]> + %s был отключен]]> Быстрое вступление и надежная доставка сообщений. Соединиться с самим собой? Связанные мобильные @@ -1599,4 +1599,11 @@ Показывать консоль в новом окне Проверять соединения Проверить соединение + Соединяться автоматически + Ожидается подключение… + %s]]> + Компьютер найден + Несовместимая версия! + автор + Найти через локальную сеть \ No newline at end of file From 6c05eb0ff3616fd44d5ff5d955bdd5f4af920936 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 24 Nov 2023 23:21:38 +0000 Subject: [PATCH 289/294] directory: support group names with spaces (#3458) --- .../src/Directory/Events.hs | 12 +++++- .../src/Directory/Service.hs | 4 +- tests/Bots/DirectoryTests.hs | 42 ++++++++++++++----- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index dab9ceb77e..89231e4db1 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -14,6 +14,7 @@ module Directory.Events DirectoryRole (..), SDirectoryRole (..), crDirectoryEvent, + viewName, ) where @@ -158,4 +159,13 @@ directoryCmdP = DCListLastGroups_ -> DCListLastGroups <$> (A.space *> A.decimal <|> pure 10) DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) where - gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> A.takeTill (== ' ') + gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP + displayNameP = quoted '\'' <|> takeNameTill (== ' ') + takeNameTill p = + A.peekChar' >>= \c -> + if refChar c then A.takeTill p else fail "invalid first character in display name" + quoted c = A.char c *> takeNameTill (== c) <* A.char c + refChar c = c > ' ' && c /= '#' && c /= '@' + +viewName :: String -> String +viewName n = if ' ' `elem` n then "'" <> n <> "'" else n diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index a30638249f..fb187bbebe 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -156,7 +156,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { askConfirmation = do ugrId <- addGroupReg st ct g GRSPendingConfirmation sendMessage cc ct $ T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already submitted to the directory.\nTo confirm the registration, please send:" - sendMessage cc ct $ "/confirm " <> show ugrId <> ":" <> T.unpack displayName + sendMessage cc ct $ "/confirm " <> show ugrId <> ":" <> viewName (T.unpack displayName) badRolesMsg :: GroupRolesStatus -> Maybe String badRolesMsg = \case @@ -301,7 +301,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { msg = maybe (MCText text) (\image -> MCImage {text, image}) image' withSuperUsers $ \cId -> do sendComposedMessage' cc cId Nothing msg - sendMessage' cc cId $ "/approve " <> show dbGroupId <> ":" <> T.unpack displayName <> " " <> show gaId + sendMessage' cc cId $ "/approve " <> show dbGroupId <> ":" <> viewName (T.unpack displayName) <> " " <> show gaId deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 36b990ba35..b31d6f36fe 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -11,6 +11,7 @@ import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) import Control.Monad (forM_) +import Directory.Events (viewName) import Directory.Options import Directory.Service import Directory.Store @@ -28,6 +29,7 @@ directoryServiceTests = do it "should register group" testDirectoryService it "should suspend and resume group" testSuspendResume it "should join found group via link" testJoinGroup + it "should support group names with spaces" testGroupNameWithSpaces describe "de-listing the group" $ do it "should de-list if owner leaves the group" testDelistedOwnerLeaves it "should de-list if owner is removed from the group" testDelistedOwnerRemoved @@ -243,6 +245,24 @@ testJoinGroup tmp = cath <## "#privacy: new member dan is connected" ] +testGroupNameWithSpaces :: HasCallStack => FilePath -> IO () +testGroupNameWithSpaces tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + registerGroup superUser bob "Privacy & Security" "" + groupFound bob "Privacy & Security" + superUser #> "@SimpleX-Directory /suspend 1:'Privacy & Security'" + superUser <# "SimpleX-Directory> > /suspend 1:'Privacy & Security'" + superUser <## " Group suspended!" + bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is suspended and hidden from directory. Please contact the administrators." + groupNotFound bob "privacy" + superUser #> "@SimpleX-Directory /resume 1:'Privacy & Security'" + superUser <# "SimpleX-Directory> > /resume 1:'Privacy & Security'" + superUser <## " Group listing resumed!" + bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is listed in the directory again!" + groupFound bob "Privacy & Security" + testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () testDelistedOwnerLeaves tmp = withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> @@ -840,16 +860,16 @@ registerGroupId su u n fn gId ugId = do submitGroup :: TestCC -> String -> String -> IO () submitGroup u n fn = do - u ##> ("/g " <> n <> " " <> fn) - u <## ("group #" <> n <> " (" <> fn <> ") is created") - u <## ("to add members use /a " <> n <> " or /create link #" <> n) - u ##> ("/a " <> n <> " SimpleX-Directory admin") - u <## ("invitation to join the group #" <> n <> " sent to SimpleX-Directory") + u ##> ("/g " <> viewName n <> if null fn then "" else " " <> fn) + u <## ("group #" <> viewName n <> (if null fn then "" else " (" <> fn <> ")") <> " is created") + u <## ("to add members use /a " <> viewName n <> " or /create link #" <> viewName n) + u ##> ("/a " <> viewName n <> " SimpleX-Directory admin") + u <## ("invitation to join the group #" <> viewName n <> " sent to SimpleX-Directory") groupAccepted :: TestCC -> String -> IO String groupAccepted u n = do u <# ("SimpleX-Directory> Joining the group " <> n <> "…") - u <## ("#" <> n <> ": SimpleX-Directory joined the group") + u <## ("#" <> viewName n <> ": SimpleX-Directory joined the group") u <# ("SimpleX-Directory> Joined the group " <> n <> ", creating the link…") u <# "SimpleX-Directory> Created the public link to join the group via this directory service that is always online." u <## "" @@ -869,7 +889,7 @@ completeRegistrationId su u n fn welcomeWithLink gId ugId = do updateProfileWithLink :: TestCC -> String -> String -> Int -> IO () updateProfileWithLink u n welcomeWithLink ugId = do - u ##> ("/set welcome " <> n <> " " <> welcomeWithLink) + u ##> ("/set welcome " <> viewName n <> " " <> welcomeWithLink) u <## "description changed to:" u <## welcomeWithLink u <# ("SimpleX-Directory> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.") @@ -879,13 +899,13 @@ notifySuperUser :: TestCC -> TestCC -> String -> String -> String -> Int -> IO ( notifySuperUser su u n fn welcomeWithLink gId = do uName <- userName u su <# ("SimpleX-Directory> " <> uName <> " submitted the group ID " <> show gId <> ":") - su <## (n <> " (" <> fn <> ")") + su <## (n <> if null fn then "" else " (" <> fn <> ")") su <## "Welcome message:" su <## welcomeWithLink su .<## "members" su <## "" su <## "To approve send:" - let approve = "/approve " <> show gId <> ":" <> n <> " 1" + let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" su <# ("SimpleX-Directory> " <> approve) approveRegistration :: TestCC -> TestCC -> String -> Int -> IO () @@ -894,7 +914,7 @@ approveRegistration su u n gId = approveRegistrationId :: TestCC -> TestCC -> String -> Int -> Int -> IO () approveRegistrationId su u n gId ugId = do - let approve = "/approve " <> show gId <> ":" <> n <> " 1" + let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" su #> ("@SimpleX-Directory " <> approve) su <# ("SimpleX-Directory> > " <> approve) su <## " Group approved!" @@ -948,7 +968,7 @@ groupFoundN count u name = do u #> ("@SimpleX-Directory " <> name) u <# ("SimpleX-Directory> > " <> name) u <## " Found 1 group(s)" - u <#. ("SimpleX-Directory> " <> name <> " (") + u <#. ("SimpleX-Directory> " <> name) u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") From 1902b692f5354541e1ddb310ff82c0681dd12b52 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 00:13:31 +0000 Subject: [PATCH 290/294] 5.4: ios 183, android 162, desktop 18 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 ++++++------ apps/multiplatform/gradle.properties | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 3d78512e3f..a9990f56b3 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1502,7 +1502,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1545,7 +1545,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1626,7 +1626,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1658,7 +1658,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1690,7 +1690,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1736,7 +1736,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 182; + CURRENT_PROJECT_VERSION = 183; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 9c817f08f4..570e982e22 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4-beta.4 -android.version_code=161 +android.version_name=5.4 +android.version_code=162 -desktop.version_name=5.4-beta.4 -desktop.version_code=17 +desktop.version_name=5.4 +desktop.version_code=18 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From de1c8855015a191cc31547c67c405b972619e3ff Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 11:22:02 +0000 Subject: [PATCH 291/294] ios: 5.4 build 184: switch to GHC 8.10.7 (9.6.3 crashes on older iPhone models), fix Connect to desktop closing when switching to QR code scan --- .../RemoteAccess/ConnectDesktopView.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 52 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 1f120860e2..e934bbc89a 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -208,7 +208,7 @@ struct ConnectDesktopView: View { Section("Found desktop") { Text("Waiting for desktop...").italic() Button { - disconnectDesktop(.dismiss) + disconnectDesktop() } label: { Label("Scan QR code", systemImage: "qrcode") } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a9990f56b3..e799041d04 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -120,11 +120,11 @@ 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; }; - 5CD67B962B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B912B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a */; }; - 5CD67B972B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B922B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a */; }; - 5CD67B982B11416700C510B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B932B11416600C510B1 /* libffi.a */; }; - 5CD67B992B11416700C510B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B942B11416600C510B1 /* libgmp.a */; }; - 5CD67B9A2B11416700C510B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B952B11416700C510B1 /* libgmpxx.a */; }; + 5CD67BA02B120ADF00C510B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9B2B120ADF00C510B1 /* libgmp.a */; }; + 5CD67BA12B120ADF00C510B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */; }; + 5CD67BA22B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */; }; + 5CD67BA32B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */; }; + 5CD67BA42B120ADF00C510B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9F2B120ADF00C510B1 /* libffi.a */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -403,11 +403,11 @@ 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; 5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = ""; }; 5CD67B8E2B0E858A00C510B1 /* hs_init.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hs_init.c; sourceTree = ""; }; - 5CD67B912B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a"; sourceTree = ""; }; - 5CD67B922B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a"; sourceTree = ""; }; - 5CD67B932B11416600C510B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CD67B942B11416600C510B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CD67B952B11416700C510B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CD67B9B2B120ADF00C510B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a"; sourceTree = ""; }; + 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a"; sourceTree = ""; }; + 5CD67B9F2B120ADF00C510B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -511,13 +511,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CD67B972B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a in Frameworks */, + 5CD67BA12B120ADF00C510B1 /* libgmpxx.a in Frameworks */, + 5CD67BA22B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 5CD67BA02B120ADF00C510B1 /* libgmp.a in Frameworks */, + 5CD67BA42B120ADF00C510B1 /* libffi.a in Frameworks */, + 5CD67BA32B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CD67B982B11416700C510B1 /* libffi.a in Frameworks */, - 5CD67B992B11416700C510B1 /* libgmp.a in Frameworks */, - 5CD67B962B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a in Frameworks */, - 5CD67B9A2B11416700C510B1 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -579,11 +579,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CD67B932B11416600C510B1 /* libffi.a */, - 5CD67B942B11416600C510B1 /* libgmp.a */, - 5CD67B952B11416700C510B1 /* libgmpxx.a */, - 5CD67B922B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a */, - 5CD67B912B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a */, + 5CD67B9F2B120ADF00C510B1 /* libffi.a */, + 5CD67B9B2B120ADF00C510B1 /* libgmp.a */, + 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */, + 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */, + 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */, ); path = Libraries; sourceTree = ""; @@ -1502,7 +1502,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1545,7 +1545,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1626,7 +1626,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1658,7 +1658,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1690,7 +1690,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1736,7 +1736,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 183; + CURRENT_PROJECT_VERSION = 184; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; From b9dd2f45c92f73615be745f3c81079ac81d16587 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 12:51:05 +0000 Subject: [PATCH 292/294] rfc: post-quantum resistant augmented double ratchet algorithm (#3463) * rfc: post-quantum resistant augmented double ratchet algorithm * update doc * replace Kyber with "some KEM" --- docs/rfcs/2023-09-30-pq-double-ratchet.md | 224 ++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/rfcs/2023-09-30-pq-double-ratchet.md diff --git a/docs/rfcs/2023-09-30-pq-double-ratchet.md b/docs/rfcs/2023-09-30-pq-double-ratchet.md new file mode 100644 index 0000000000..255051320d --- /dev/null +++ b/docs/rfcs/2023-09-30-pq-double-ratchet.md @@ -0,0 +1,224 @@ +# Post-quantum resistant augmented double ratchet algorithm + +- [Overview](#overview) +- [Problem](#problem) +- [Comparison of the proposed solutions](#comparison-of-the-proposed-solutions) + - [PQXDH for post-quantum key agreement](#pqxdh-for-post-quantum-key-agreement) (Signal) + - [Hybrid Signal protocol for post-quantum encryption](#hybrid-signal-protocol-for-post-quantum-encryption) (Tutanota) + - [Problems with ML-KEM / Kyber](#problems-with-ml-kem--kyber) +- [Proposed solution: augmented double ratchet algorithm](#proposed-solution-augmented-double-ratchet-algorithm) +- [Double ratchet with encrypted headers augmented with double PQ KEM](#double-ratchet-with-encrypted-headers-augmented-with-double-pq-kem) + - [Initialization](#initialization) + - [Encrypting messages](#encrypting-messages) + - [Decrypting messages](#decrypting-messages) +- [Alternative approach: CTIDH](#alternative-approach-ctidh) +- [Implementation considerations for SimpleX Chat](#implementation-considerations-for-simplex-chat) +- [Summary](#summary) +- [Notes](#notes) + +## 1. Overview + +Currently SimpleX Chat uses [double-ratchet with header encryption](https://signal.org/docs/specifications/doubleratchet/#double-ratchet-with-header-encryption) to provide end-to-end encryption to messages and files. This document proposes a way to augment this algorithm with post-quantum key encapsulation mechanism (KEM) to make it resistant to quantum computers. + +This document is purposefully written in an informal style to make it understandable for general audience with some technical, but without mathematical background. It does not compromise on the technical accuracy though. + +## 2. Problem + +It is a reasonable assumption that "record-now-decrypt-later" attacks are ongoing, so the users want to use cryptographic schemes for end-to-end encryption that are augmented with some post-quantum algorithm that is believed to be resistant to quantum computers. + +Double-ratchet algorithm is a state of the art solution for end to end encryption offering a set of qualities that is unmatched by any other algorithm: + +- perfect forward secrecy, i.e. compromise of session or long term keys does not lead to the ability to decrypt any of the past messages. +- deniability (also known as repudiation), i.e. the fact that the recipient of the message cannot prove to a third party that the sender actually sent this message [1]. +- break-in recovery [2] (also know as post-compromise security or future secrecy), i.e. the ability of the end-to-end encryption security to recover from the compromise of the long term keys. This is achieved by generating a new random key pair whenever a new DH key is received (DH ratchet step). + +It is desirable to preserve all these qualities when augmenting the algorithm with a post-quantum algorithm, and having these qualities resistant (or "believed to be" resistant [3]) to both conventional and quantum computers. + +## Comparison of the proposed solutions + +### PQXDH for post-quantum key agreement + +[The solution](https://signal.org/docs/specifications/pqxdh/) recently [introduced by Signal](https://signal.org/blog/pqxdh/) augments the initial key agreement ([X3DH](https://signal.org/docs/specifications/x3dh/)) that is made prior to double ratchet algorithm. This is believed to provide protection from "record-now-decrypt-later" attack, but if the attacker at any point obtains long term keys from any of the devices, the break-in recovery will not be post-quantum resistant, and the attacker would be able to decrypt all the subsequent messages. + +In addition to that, the authentication of parties in the proposed scheme is also not post-quantum resistant, although this is not part of double ratchet algorithm. + +### Hybrid Signal protocol for post-quantum encryption + +[The solution](https://eprint.iacr.org/2021/875.pdf) [proposed by Tutanota](https://tutanota.com/blog/posts/pqmail-update/) aims to preserve the break-in recovery property of double ratchet, but in doing so it: +- replaces rather than augments DH key agreement with post-quantum KEM mechanism, making it potentially vulnerable to conventional computers. +- adds signature to the DH ratchet step, to compensate for not keeping DH key agreement, but losing the deniability property for some of the messages. + +### Problems with ML-KEM / Kyber + +ML-KEM / Kyber used in both Signal and Tutanota schemes is the chosen algorithm by NIST, but its standardisation process raised some concerns amongst the experts: + +- hashing of random numbers that was present in the original submission was removed from the standardised version of the algorithm. See lines 304-314 in the published spec (https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.203.ipd.pdf) and also the linked discussion on the subject: https://groups.google.com/a/list.nist.gov/g/pqc-forum/c/WFRDl8DqYQ4. This decision polarised the experts, with some of them saying that it effectively created a backdoor. +- calculation of security levels of Kyber appears to have been done incorrectly, and overall, the chosen Kyber seems worse than rejected NTRU according to [the analysis by Daniel Bernstein](https://blog.cr.yp.to/20231003-countcorrectly.html). + +## Proposed solution: augmented double ratchet algorithm + +We will augment the double ratchet algorithm with post-quantum KEM mechanism, preserving all properties of the double ratchet algorithm. + +It is possible, because although double ratchet uses DH (which is a non-interactive key exchange [4]), it uses it "interactively", when the new DH keys are generated by both parties in turns. Parties of double-ratchet encrypted communication can run one or two post-quantum key encapsulation mechanisms in parallel with DH key agreement on each DH ratchet step, making break-in recovery of double ratchet algorithm post-quantum resistant (unlike Signal PQXDH scheme), without losing deniability or resistance to conventional computers (unlike Tutanota scheme). + +Specifically, it is proposed to augment [double ratchet with encrypted headers](https://signal.org/docs/specifications/doubleratchet/#double-ratchet-with-header-encryption) with some post-quantum key encapsulation mechanism (KEM) as described below. A possible algorithm for PQ KEM is [NTRU-prime](https://ntruprime.cr.yp.to), that is currently adopted in SSH and has available implementations. It is important though that the proposed scheme can be used with any PQ KEM algorithm. + +The downside of the proposed scheme is its substantial size overhead, as the encapsulation key and/or encapsulated shared secret are added to the header of each message. For the scheme described below, when both the encapsulation key and encapsulated secret are added to each message header, NTRU-prime adds ~2-4kb to each message (depending on the key size and the chosen variant). See [this table](https://ntruprime.cr.yp.to/security.html) for key and ciphertext sizes and the assessment of the security level for various key sizes. + +## Double ratchet with encrypted headers augmented with double PQ KEM + +Algorithm below assumes that in addition to shared secret from the initial key agreement, there will be an encapsulation key available from the party that published its keys (Bob). + +### Initialization + +The double ratchet initialization is defined in pseudocode as follows: + +``` +// Alice obtained Bob's keys and initializes ratchet first +def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb, bob_pq_kem_encapsulation_key): + state.DHRs = GENERATE_DH() + state.DHRr = bob_dh_public_key + // below added for post-quantum KEM + state.PQRs = GENERATE_PQKEM() + state.PQRr = bob_pq_kem_encapsulation_key + state.PQRss = random // shared secret for KEM + state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret + // above added for KEM + // below augments DH key agreement with PQ shared secret + state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss) + state.CKr = None + state.Ns = 0 + state.Nr = 0 + state.PN = 0 + state.MKSKIPPED = {} + state.HKs = shared_hka + state.HKr = None + state.NHKr = shared_nhkb + +// Bob initializes ratchet second, having received Alice's connection request +def RatchetInitBobPQ2HE(state, SK, bob_dh_key_pair, shared_hka, shared_nhkb, bob_pq_kem_key_pair): + state.DHRs = bob_dh_key_pair + state.DHRr = None + // below added for KEM + state.PQRs = bob_pq_kem_key_pair + state.PQRr = None + state.PQRss = None + state.PQRenc_ss = None + // above added for KEM + state.RK = SK + state.CKs = None + state.CKr = None + state.Ns = 0 + state.Nr = 0 + state.PN = 0 + state.MKSKIPPED = {} + state.HKs = None + state.NHKs = shared_nhkb + state.HKr = None + state.NHKr = shared_hka +``` + +`GENERATE_PQKEM` generates decapsulation/encapsulation key pair. + +`PQKEM-ENC` is key encapsulation algorithm. + +Other than commented lines, the above adds parameters `bob_pq_kem_encapsulation_key` and `bob_pq_kem_key_pair` to the ratchet intialization. Otherwise it is identical to the original double ratchet initialization. + +### Encrypting messages + +``` +def RatchetEncryptPQ2HE(state, plaintext, AD): + state.CKs, mk = KDF_CK(state.CKs) + // encapsulation key from PQRs and encapsulated shared secret is added to header + header = HEADER_PQ2( + dh = state.DHRs.public, + pn = state.PN, + n = state.Ns, + encaps = state.PQRs.encaps, // added for KEM #1 + enc_ss = state.PQRenc_ss // added for KEM #2 + ) + enc_header = HENCRYPT(state.HKs, header) + state.Ns += 1 + return enc_header, ENCRYPT(mk, plaintext, CONCAT(AD, enc_header)) +``` + +Other than adding encapsulation key and encapsulated shared secret into the header, the above is identical to the original double ratchet message encryption step. + +As an optimization, to save space, it might be possible to add encapsulation key and encapsulated secret only when they change. The downside of this optimization would be that it will be impossible to decrypt the message when the message that has them is skipped or lost, compromising the ability of double ratchet to manage skipped messages. + +### Decrypting messages + +``` +def RatchetDecryptPQ2HE(state, enc_header, ciphertext, AD): + plaintext = TrySkippedMessageKeysHE(state, enc_header, ciphertext, AD) + if plaintext != None: + return plaintext + header, dh_ratchet = DecryptHeader(state, enc_header) // DecryptHeader is the same as in double ratchet specification + if dh_ratchet: + SkipMessageKeysHE(state, header.pn) // SkipMessageKeysHE is the same as in double ratchet specification + DHRatchetPQ2HE(state, header) + SkipMessageKeysHE(state, header.n) + state.CKr, mk = KDF_CK(state.CKr) + state.Nr += 1 + return DECRYPT(mk, ciphertext, CONCAT(AD, enc_header)) + +def DHRatchetPQ2HE(state, header): + state.PN = state.Ns + state.Ns = 0 + state.Nr = 0 + state.HKs = state.NHKs + state.HKr = state.NHKr + state.DHRr = header.dh + // save new encapsulation key from header + state.PQRr = header.encaps + // decapsulate shared secret from header - KEM #2 + ss = PQKEM-DEC(state.PQRs.decaps, header.enc_ss) + // use decapsulated shared secret with receiving ratchet + state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss) + state.DHRs = GENERATE_DH() + // below is added for KEM + state.PQRs = GENERATE_PQKEM() // generate new PQ key pair + state.PQRss = random // shared secret for KEM + state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret KEM #1 + // above is added for KEM + // use new shared secret with sending ratchet + state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || state.PQRss) +``` + +`PQKEM-DEC` is key decapsulation algorithm. + +`DHRatchetPQ2HE` augments both DH agreements with decapsulated shared secret from the received header and with the new shared secret, respectively. The new shared secret together with the new encapsulation key are saved in the state and will be added to the header in the next sent message. + +Other than augmenting DH key agreements with the shared secrets from KEM, the above is identical to the original double ratchet DH ratchet step. + +It is worth noting that while DH agreements work as ping-pong, when the new received DH key is used for both DH agreements (and only the sent DH key is updated for the second DH key agreement), PQ KEM agreements in the proposed scheme work as a "parallel ping-pong", with two balls in play all the time (two KEM agreements run in parallel). + +## Alternative approach: CTIDH + +Instead of using KEM, we can consider using [CTIDH](https://ctidh.isogeny.org). The advantage is a smaller key size, and also as CTIDH is non-interactive [4], there is no need to run two key agreements in parallel, the PQ keys would simply augment DH keys and would be used in the same way. + +The main downside is the absense of performance-efficient implementation for aarch64 architecture. + +## Implementation considerations for SimpleX Chat + +As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality) or sending additional messages. + +That might be the primary reason why this scheme was not adopted by Signal, as it would have resulted in substantial traffic growth – to the best of our knowledge, Signal messages are not padded to a fixed size. + +Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the invitation link. As encapsulation key is large, it may be inconvenient to share it in the link in some contexts. + +It is possible to postpone sharing the encapsulation key until the first message from Alice (confirmation message in SMP protocol), the party sending connection request. The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm. To mitigate it, the hadnshake protocol can be modified to postpone sending the user profile until the second message from Alice (HELLO message in SMP protocol). + +## Summary + +If chosen PQ KEM proves secure against quantum computer attacks, then the proposed augmented double ratchet will also be secure against quantum computer attack, including break-in recovery property, while keeping deniability and forward secrecy, because the [same proof](https://eprint.iacr.org/2016/1013.pdf) as for double ratchet algorithm would hold here, provided KEM is secure. + +## Notes + +[1] This is often misunderstood to mean that the recipient cannot prove that the sender sent the message at all, which is incorrect, as the recipient has the proof that either themselves or the sender encrypted the message, and as they know that the recipient themselves did not encrypt it, therefore the sender did. So the communication is secure and authenticated for the parties, without providing a proof to a third party. + +[2] The term "break-in recovery" is used in this document, consistent with the terminology of the double ratchet algorithm specification, and also because it can be used to mean both the quality of being able to recover from the compromise and the actual process used to recover. + +[3] This is important to remember that no existing post-quantum algorithms are proven to be resistant to quantum or even conventional computers, therefore the correct approach is to augment rather than replace existing algorithms with the post-quantum ones. + +[4] Non-interactive key exchange is a type of key agreement that allows both parties to generate key pairs independently, without input from another parties, and then use the public keys from each other to compute the same shared secret. From 962964a73d3fe9a2a83bbccebc7e15e4335629e7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 14:15:41 +0000 Subject: [PATCH 293/294] docs: update downloads page --- docs/DOWNLOADS.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index a1576c355e..a43a694097 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -1,13 +1,13 @@ --- title: Download SimpleX apps permalink: /downloads/index.html -revision: 01.10.2023 +revision: 25.11.2023 --- -| Updated 01.10.2023 | Languages: EN | +| Updated 25.11.2023 | Languages: EN | # Download SimpleX apps -The latest stable version is v5.3.2. +The latest stable version is v5.4.0. You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). @@ -21,24 +21,24 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.3/simplex-desktop-windows-x86-64.msi) (BETA). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-windows-x86_64.msi). ## Mobile apps **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-windows-x86-64). From 1e6891e222837cdac990a7187978b10c988f4d40 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 25 Nov 2023 18:27:41 +0000 Subject: [PATCH 294/294] blog: v5.4 announcement (#3457) * blog: v5.4 announcement * update * corrections Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * add images, CLI section * images * preview, readme * correction --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- README.md | 10 +- ...desktop-quantum-resistant-better-groups.md | 128 ++++++++++++++---- blog/README.md | 22 +++ blog/images/20231125-block.png | Bin 0 -> 1137734 bytes blog/images/20231125-desktop1.png | Bin 0 -> 43571 bytes blog/images/20231125-desktop2.png | Bin 0 -> 31992 bytes blog/images/20231125-desktop3.png | Bin 0 -> 115839 bytes blog/images/20231125-desktop4.png | Bin 0 -> 450951 bytes blog/images/20231125-group1.png | Bin 0 -> 798614 bytes blog/images/20231125-group2.png | Bin 0 -> 548108 bytes blog/images/20231125-mobile1.png | Bin 0 -> 400610 bytes blog/images/20231125-mobile1a.png | Bin 0 -> 268967 bytes blog/images/20231125-mobile2.png | Bin 0 -> 766821 bytes blog/images/20231125-mobile3.png | Bin 0 -> 204927 bytes blog/images/20231125-mobile4.png | Bin 0 -> 204082 bytes blog/images/arrow.png | Bin 0 -> 8789 bytes .../src/_includes/blog_previews/20231125.html | 15 ++ 17 files changed, 147 insertions(+), 28 deletions(-) create mode 100644 blog/images/20231125-block.png create mode 100644 blog/images/20231125-desktop1.png create mode 100644 blog/images/20231125-desktop2.png create mode 100644 blog/images/20231125-desktop3.png create mode 100644 blog/images/20231125-desktop4.png create mode 100644 blog/images/20231125-group1.png create mode 100644 blog/images/20231125-group2.png create mode 100644 blog/images/20231125-mobile1.png create mode 100644 blog/images/20231125-mobile1a.png create mode 100644 blog/images/20231125-mobile2.png create mode 100644 blog/images/20231125-mobile3.png create mode 100644 blog/images/20231125-mobile4.png create mode 100644 blog/images/arrow.png create mode 100644 website/src/_includes/blog_previews/20231125.html diff --git a/README.md b/README.md index 66465926dd..254a66c2bc 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,8 @@ You can use SimpleX with your own servers and still communicate with people usin Recent and important updates: +[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md). + [Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md). [Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md). @@ -366,13 +368,13 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A - ✅ Message delivery confirmation (with sender opt-out per contact). - ✅ Desktop client. - ✅ Encryption of local files stored in the app. -- 🏗 Using mobile profiles from the desktop app. +- ✅ Using mobile profiles from the desktop app. +- 🏗 Improve experience for the new users. +- 🏗 Post-quantum resistant key exchange in double ratchet protocol. +- 🏗 Large groups, communities and public channels. - Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic). -- Post-quantum resistant key exchange in double ratchet protocol. -- Large groups, communities and public channels. - Privacy & security slider - a simple way to set all settings at once. - Improve sending videos (including encryption of locally stored videos). -- Improve experience for the new users. - SMP queue redundancy and rotation (manual is supported). - Include optional message into connection request sent via contact address. - Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.). diff --git a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md index aef4dddf64..4fbfc400ad 100644 --- a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md +++ b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md @@ -2,48 +2,128 @@ layout: layouts/article.html title: "SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups." date: 2023-11-25 -preview: SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups. -# image: images/20231125-remote-desktop.jpg -draft: true -imageWide: true -permalink: "/blog/20231125-simplex-chat-v5-4-quantum-resistant-mobile-from-desktop-better-groups.html" +previewBody: blog_previews/20231125.html +image: images/20231125-mobile2.png +permalink: "/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html" --- -TODO stub for release announcement - # SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups. **Published:** Nov 25, 2023 -- [Quick start: control SimpleX Chat mobile app from CLI](#⚡️-quick-start-use-profiles-in-SimpleX-Chat-mobile-from-desktop-app) -- [What's the problem](#whats-the-problem) -- [Why didn't we use some existing solution?](#why-didnt-we-use-some-existing-solution) +**What's new in v5.4:** +- [Link mobile and desktop apps via secure quantum-resistant protocol](#link-mobile-and-desktop-apps-via-secure-quantum-resistant-protocol). + - ⚡️ Quick start - how to use it. + - How does it work? + - 🤖 Connecting to remote CLI. +- [Better groups](#better-groups). + - [Faster to join and more reliable](#faster-to-join-with-more-reliable-message-delivery). + - [New group features](#new-group-features): + - create groups with incognito profile, + - block group members to reduce noise, + - prohibit files and media in a group. +- [Better calls](#better-calls): faster to connect, with screen sharing on desktop. -## ⚡️ Quick start: use profiles in SimpleX Chat mobile from desktop app +There are many [other improvements](#other-improvements) and fixes in this release: + - profile names now allow spaces. + - when you delete contacts, they are optionally notified. + - previously used and your own SimpleX links are recognized by the app. + - and more - see the [release notes](https://github.com/simplex-chat/simplex-chat/releases/tag/v5.4.0). -## What's the problem? +## Link mobile and desktop apps via secure quantum-resistant protocol -Currently you cannot use the same SimpleX Chat profile on mobile and desktop devices. Even though you can use small groups instead of direct conversations as a workaround, it is quite inconvenient – read status and delivery receipts become much less useful. +This release allows to use chat profiles you have in mobile app from desktop app. -So, we need a way to use the same profile on desktop as we use on mobile. +This is only possible when both devices are connected to the same local network. To send and receive messages mobile app has to be connected to the Internet. -If SimpleX Chat profile was stored on the server, the problem would have been simpler - you can just connect to it from another device. But even in this case, accessing the conversation history without compromising the security of double ratchet end-to-end encryption is not really possible. +### ⚡️ Quick start - how to use it -So we decided to implement the solution that is similar to what WhatsApp and WeChat did in early days - allowing a desktop device access profile on mobile via network. Unlike these big apps, we don't use the server to connect to mobile, but instead use the connection over the local network. +**On desktop** -The downside of this approach is that mobile device has to be with you and connected to the same local network (and in case of iOS, the app has to be in the foreground as well). But the upside is that we the connection can be secure, and that you do not have to have a copy of your profiles on the desktop, which usually has lower security. +If you don't have desktop app installed yet, [download it](https://simplex.chat/downloads/) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer. -## Why didn't we use some existing solution? +Then in desktop app settings choose *Link a mobile* - it will show a QR code. -While there are several existing protocols for remote access, all of them are vulnerable to spoofing and man + - in many cases support of sending files and images is not very good, and sending videos and large files is simply impossible. There are currently these problems: +**On mobile** -- the sender has to be online for file transfer to complete, once it was confirmed by the recipient. -- when the file is sent to the group, the sender will have to transfer it separately to each member, creating a lot of traffic. -- the file transfer is slow, as it is sent in small chunks - approximately 16kb per message. +In mobile app settings choose *Use from desktop*, scan the QR code and verify session code when it appears on both devices - it should be the same. Verifying session code confirms that the devices are connected directly via a secure encrypted connection. There is an option to verify this code on subsequent connections too, but by default it is only required once. -As a result, we limited the supported size of files in the app to 8mb. Even for supported files, it is quite inefficient for sending any files to large groups. + + +The devices are now paired, and you can continue using all mobile profiles from desktop. + +If it is an Android app, you can move the app to background, but iOS app has to remain open. In both cases, while you are using mobile profiles from desktop, you won't be able to use mobile app. + +The subsequent connections happen much faster - by default, the desktop app broadcasts its session address to the network, in encrypted form, and mobile app connects to it once you choose *Use from desktop* in mobile app settings. + +### How does it work? + +The way we designed this solution avoided any security compromises, and the end-to-end encryption remained as secure as it was - it uses [double-ratchet algorithm](../docs/GLOSSARY.md#double-ratchet-algorithm), with [perfect forward secrecy](../docs/GLOSSARY.md#forward-secrecy), [post-compromise security](../docs/GLOSSARY.md#post-compromise-security) and deniability. + +This solution is similar to WhatsApp and WeChat. But unlike these apps, no server is involved in the connection between mobile and desktop. The connection itself uses a new SimpleX Remote Control Protocol (XRCP) based on secure TLS 1.3 and additional quantum-resistant encryption inside TLS. You can read XRCP protocol specification and threat model in [this document](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2023-10-25-remote-control.md). We will soon be [augmenting double ratchet](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2023-09-30-pq-double-ratchet.md) to be resistant to quantum computers as well. + +The downside of this approach is that mobile device has to be connected to the same local network as desktop. But the upside is that the connection is secure, and you do not need to have a copy of all your data on desktop, which usually has lower security than mobile. + +Please note, that the files you send, save or play from desktop app, and also images you view are automatically saved on your desktop device (encrypted by default except videos). To remove all these files you can unlink the paired mobile device from the desktop app settings – there will be an option soon allowing to remove the files without unlinking the mobile. + +### 🤖 Connecting to remote SimpleX CLI + +*Warning*: this section is for technically advanced users! + +If you run SimpleX CLI on a computer in another network - e.g., in the cloud VM or on a Raspberry Pi at home while you are at work, you can also use if from desktop via SSH tunnel. Below assumes that you have remote machine connected via SSH and CLI running there - you can use `tmux` for it to keep running when you are not connected via ssh. + +Follow these steps to use remote CLI from desktop app: +1. On the remote machine add the IP address of your desktop to the firewall rules, so that when CLI tries to connect to this address, it connects to `localhost` instead: `iptables -t nat -A OUTPUT -p all -d 192.168.1.100 -j DNAT --to-destination 127.0.0.1` (replace `192.168.1.100` with the actual address of your desktop, and make sure it is not needed for something else on your remote machine). +2. Also on the remote machine, run Simplex CLI with the option `--device-name 'SimpleX CLI'`, or any other name you like. You can also use the command `/set device name ` to set it for the CLI. +3. Choose *Link a mobile* in desktop app settings, note the port it shows under the QR code, and click "Share link". +4. Run ssh port forwarding on desktop computer to let your remote machine connect to desktop app: `ssh -R 12345:127.0.0.1:12345 -N user@example.com` where `12345` is the port on which desktop app is listening for the connections from step 3, `example.com` is the hostname or IP address of your remote machine, and `user` is some username on remote machine. You can run port forwarding in the background by adding `-f` option. +5. On the remote machine, run CLI command `/connect remote ctrl `, where `` is the desktop session address copied in step 3. You should run this command within 1 minute from choosing *Link a mobile*. +6. If the connection is successful, the CLI will ask you to verify the session code (you need to copy and paste the command) with the one shown in desktop app. Once you use `/verify remote ctrl ` command, CLI can be used from desktop app. +7. To stop remote session use `/stop remote ctrl` command. + +## Better groups + +### Faster to join, with more reliable message delivery + +We improved the protocols for groups, by making joining groups much faster, and also by adding message forwarding. Previously, the problem was that until a new member connects directly with each existing group member, they did not see each other messages in the group. The problem is explained in detail in [this video](https://www.youtube.com/watch?v=7yjQFmhAftE&t=1104s) at 18:23. + +With v5.4, the admin who added members to the group forwards messages to and from the new members until they connect to the existing members. So you should no longer miss any messages and be surprised with replies to messages you have never seen once you and new group members upgrade. + +### New group features + + + +**Create groups with incognito profile** + +Previously, you could only create groups with your main profile. This version allows creating groups with incognito profile directly. You will not be able to add your contacts, they can only join via group link. + +**Block group members to reduce noise** + +You now can block messages from group members that send too many messages, or the messages you don't won't to see. Blocked members won't know that you blocked their messages. When they send messages they will appear in the conversation as one line, showing how many messages were blocked. You can reveal them, or delete all sequential blocked messages at once. + +**Prohibit files and media in a group** + +Group owners now have an option to prohibit sending files and media. This can be useful if you don't won't any images shared, and only want to allow text messages. + +## Better calls + +Calls in SimpleX Chat still require a lot of work to become stable, but this version improved the speed of connecting calls, and they should work for more users. + +We also added screen sharing in video calls to desktop app. + +## Other improvements + +This version also has many small and large improvements to make the app more usable and reliable. + +The new users and group profiles now allow spaces in the names, to make them more readable. To message these contacts in CLI you need to use quotes, for example, `@'John Doe' Hello!`. + +When you delete contacts, you can notify them - to let them know they can't message you. + +When you try to connect to the same contact or join the same group, or connect via your own link, the app will recognize it and warn you, or simply open the correct conversation. + +You can find the full list of fixed bugs and small improvements in the [release notes](https://github.com/simplex-chat/simplex-chat/releases/tag/v5.4.0). ## SimpleX platform diff --git a/blog/README.md b/blog/README.md index 129ba2c80d..815009139f 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,27 @@ # Blog +Nov 25, 2023 [SimpleX Chat v5.4 released](./20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md) + +- Link mobile and desktop apps via secure quantum-resistant protocol. 🔗 +- Better groups: + - faster to join and more reliable. + - create groups with incognito profile. + - block group members to reduce noise. + - prohibit files and media in a group. +- Better calls: faster to connect, with screen sharing on desktop. +- Many other improvements. + +--- + +Sep 25, 2023 [SimpleX Chat v5.3 released](./20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md) + +- new desktop app! 💻 +- directory service and other group improvements. +- encrypted local files and media with forward secrecy. +- simplified incognito mode. + +--- + July 22, 2023 [SimpleX Chat v5.2 released](./20230722-simplex-chat-v5-2-message-delivery-receipts.md) **What's new in v5.2:** diff --git a/blog/images/20231125-block.png b/blog/images/20231125-block.png new file mode 100644 index 0000000000000000000000000000000000000000..1928b7a6fd2d59a486798ae05a774a6b7c1f54dc GIT binary patch literal 1137734 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