Compare commits
624 Commits
v0.5.5
...
_archived-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c57c82d4 | ||
|
|
e01be483da | ||
|
|
c7b5d73512 | ||
|
|
235fb8dc0c | ||
|
|
44a9ea2102 | ||
|
|
121541759b | ||
|
|
716a941dc6 | ||
|
|
16bd9ccc4f | ||
|
|
33e702d453 | ||
|
|
883bf768af | ||
|
|
f341e54128 | ||
|
|
99bd3f6133 | ||
|
|
7590502f29 | ||
|
|
f770a8396e | ||
|
|
b0f3d59cb0 | ||
|
|
cab5bc2daf | ||
|
|
935c3987b3 | ||
|
|
084d1d09a5 | ||
|
|
47ec486201 | ||
|
|
72103949a7 | ||
|
|
3b708105a4 | ||
|
|
800efb3a34 | ||
|
|
360553deeb | ||
|
|
b62f2acca7 | ||
|
|
af3dcc4a9a | ||
|
|
b0a81252c9 | ||
|
|
949fb17406 | ||
|
|
e15d4ac6b6 | ||
|
|
b435c0145f | ||
|
|
87c0c9de91 | ||
|
|
cd1af400bb | ||
|
|
17b8101d88 | ||
|
|
1b972bc7cc | ||
|
|
e1e161539d | ||
|
|
4db7e88ed8 | ||
|
|
82a4a8c6f8 | ||
|
|
15ddefe86b | ||
|
|
fa844c48e9 | ||
|
|
7e96da95f9 | ||
|
|
0bb5774ff1 | ||
|
|
866d84e7ac | ||
|
|
d6262bc2a4 | ||
|
|
e5909d4e12 | ||
|
|
23b75f11fe | ||
|
|
71fa2bfec0 | ||
|
|
29e2c00811 | ||
|
|
7c1d573a17 | ||
|
|
89908ef5dc | ||
|
|
c3c712aa02 | ||
|
|
b56ad77502 | ||
|
|
5e476516cb | ||
|
|
ce2f3c0371 | ||
|
|
da13e6614b | ||
|
|
79d9e90ab7 | ||
|
|
387aec8593 | ||
|
|
a403f2051a | ||
|
|
9e83b54b85 | ||
|
|
2696086faa | ||
|
|
546ad01fcb | ||
|
|
247e7f1ea7 | ||
|
|
0290a687af | ||
|
|
97cab8b542 | ||
|
|
4a42797d83 | ||
|
|
3051732622 | ||
|
|
3fe005e252 | ||
|
|
79dcada757 | ||
|
|
f7eeb4d1e3 | ||
|
|
d572cfbc09 | ||
|
|
cb95c51fe1 | ||
|
|
e057f9e407 | ||
|
|
6333a60103 | ||
|
|
3a539aec5b | ||
|
|
40be12430e | ||
|
|
53d66be910 | ||
|
|
d6699ffb03 | ||
|
|
6ad2eeec89 | ||
|
|
d971e7c31f | ||
|
|
d2d8498258 | ||
|
|
c1f67c08f7 | ||
|
|
70f78e7984 | ||
|
|
7f84057b86 | ||
|
|
718c8f826a | ||
|
|
e13cb6e2fd | ||
|
|
cb529e3202 | ||
|
|
b3e67efba0 | ||
|
|
3f2ca8f902 | ||
|
|
9c9f6d8443 | ||
|
|
d50ebbd061 | ||
|
|
6cc4323571 | ||
|
|
5cddf8e2d3 | ||
|
|
2c2ab98105 | ||
|
|
5fd75ee286 | ||
|
|
0a134e2ded | ||
|
|
e4a66c767c | ||
|
|
e4b1ff5e0f | ||
|
|
5f67c450b1 | ||
|
|
dc418923ac | ||
|
|
106dceabfc | ||
|
|
295cec7c53 | ||
|
|
84bf815e5c | ||
|
|
82445ec8d5 | ||
|
|
36ef6df9fb | ||
|
|
c000a1b924 | ||
|
|
9bf7821444 | ||
|
|
03eaa94324 | ||
|
|
7ad7f4f91a | ||
|
|
8d53c569c7 | ||
|
|
b7860ad0e8 | ||
|
|
9f5ea49676 | ||
|
|
c1eed47463 | ||
|
|
91a0283a36 | ||
|
|
da793bbf4f | ||
|
|
e958e45652 | ||
|
|
66284a954b | ||
|
|
6aebb93f7f | ||
|
|
53e330dac9 | ||
|
|
e174c43bec | ||
|
|
18c3f49f96 | ||
|
|
aa9c2f3228 | ||
|
|
1c5e6f52ec | ||
|
|
1a653649ec | ||
|
|
50f06a3c55 | ||
|
|
81ebf1b696 | ||
|
|
885a4ea972 | ||
|
|
0262ab53bf | ||
|
|
89ea57e4b6 | ||
|
|
3d8ccdaa9f | ||
|
|
058c0f5895 | ||
|
|
1027bf923f | ||
|
|
01467769bf | ||
|
|
3cd8f8f7dd | ||
|
|
05283d58b0 | ||
|
|
c0528baba7 | ||
|
|
412982cc01 | ||
|
|
69e21781df | ||
|
|
4dcf1f8d15 | ||
|
|
ee3d7ae97e | ||
|
|
a3e6582a64 | ||
|
|
3e4826395e | ||
|
|
95c79c1b5c | ||
|
|
c61707a358 | ||
|
|
5e9d72d309 | ||
|
|
dcaefd6566 | ||
|
|
3d2315a117 | ||
|
|
1dc3190159 | ||
|
|
ca4d1910db | ||
|
|
58b37bf114 | ||
|
|
8773a058bf | ||
|
|
1530d1e12b | ||
|
|
2c61358cb9 | ||
|
|
b1024be74d | ||
|
|
b3641bdf83 | ||
|
|
c4f4cd85c4 | ||
|
|
235bce8e2a | ||
|
|
fcb5c69281 | ||
|
|
29990765e7 | ||
|
|
884231369f | ||
|
|
e575e97019 | ||
|
|
9d47c8a3d4 | ||
|
|
c8aa8db973 | ||
|
|
e80f617840 | ||
|
|
7928cdbfb8 | ||
|
|
dbf6b1f673 | ||
|
|
cf04a9fed3 | ||
|
|
76a9b5b8d4 | ||
|
|
80c5a151a2 | ||
|
|
96b514af45 | ||
|
|
f2036236f6 | ||
|
|
3e19e495de | ||
|
|
1ddd17839b | ||
|
|
70ea803a49 | ||
|
|
2c1ad9a641 | ||
|
|
260a758b82 | ||
|
|
8e002eed1c | ||
|
|
20d253ea35 | ||
|
|
3519032784 | ||
|
|
cdb919db96 | ||
|
|
f78ec3584f | ||
|
|
18d1a0605e | ||
|
|
63f531259d | ||
|
|
94a4f33a1f | ||
|
|
26d3e71c4e | ||
|
|
606eefa45d | ||
|
|
42959cc350 | ||
|
|
2b1ab01efe | ||
|
|
dd592c7db3 | ||
|
|
305052ecaf | ||
|
|
099f25c63f | ||
|
|
6c72db58f5 | ||
|
|
1df9a1ec2d | ||
|
|
d9572cef86 | ||
|
|
c48a516586 | ||
|
|
a7554771a0 | ||
|
|
645587431d | ||
|
|
f02dcc851e | ||
|
|
762024dfd9 | ||
|
|
fc5cdc5eb1 | ||
|
|
44de6297ee | ||
|
|
cd2eb9c88e | ||
|
|
5fc1364fd3 | ||
|
|
b5022b4d41 | ||
|
|
89c36d42e2 | ||
|
|
db4731f19b | ||
|
|
0470f9cf36 | ||
|
|
e87660974e | ||
|
|
48ba6472b6 | ||
|
|
9de28c46a0 | ||
|
|
14514050ae | ||
|
|
a525f24969 | ||
|
|
4a2bfef4b3 | ||
|
|
0c37282cd3 | ||
|
|
e6fdb40c59 | ||
|
|
0091e9f162 | ||
|
|
8257842914 | ||
|
|
dcffdf83b9 | ||
|
|
1932873776 | ||
|
|
7c2edff81f | ||
|
|
f594774579 | ||
|
|
03b8cdea8d | ||
|
|
effd37402a | ||
|
|
43e560c901 | ||
|
|
ec5aea0773 | ||
|
|
9a32bb8959 | ||
|
|
4cac08cf51 | ||
|
|
3e1fa779b9 | ||
|
|
5119bb3625 | ||
|
|
d61c8a363a | ||
|
|
059a13576b | ||
|
|
2e46092d92 | ||
|
|
44fd13b836 | ||
|
|
015329fcc7 | ||
|
|
6835b6c1dd | ||
|
|
1152b5d737 | ||
|
|
de81afc727 | ||
|
|
ffb941ac4d | ||
|
|
4b39de6c4f | ||
|
|
bc9a8bc32c | ||
|
|
757ca74482 | ||
|
|
87c688a739 | ||
|
|
d201c9528a | ||
|
|
2058e904e6 | ||
|
|
e560ed8327 | ||
|
|
5281871aa6 | ||
|
|
f83704c964 | ||
|
|
1431002829 | ||
|
|
f1356ca642 | ||
|
|
07c7799523 | ||
|
|
7ab76528a0 | ||
|
|
a0a14889b1 | ||
|
|
78133ff4d2 | ||
|
|
34c513adeb | ||
|
|
31eabf07e4 | ||
|
|
af471d0077 | ||
|
|
0a17f5c491 | ||
|
|
7f8afb0c12 | ||
|
|
1b930e717a | ||
|
|
02d21145b2 | ||
|
|
fa313caa82 | ||
|
|
0ac9785e4b | ||
|
|
fd69b673d8 | ||
|
|
6c2fb822d7 | ||
|
|
13f84f2a96 | ||
|
|
150b4196ea | ||
|
|
84a77de53c | ||
|
|
d90c4261b8 | ||
|
|
9fda89d0db | ||
|
|
8ef27de503 | ||
|
|
238cc8b90b | ||
|
|
f12b5524fd | ||
|
|
3f86737d3f | ||
|
|
082e62c56b | ||
|
|
8dd324b9b3 | ||
|
|
de64f3a1a0 | ||
|
|
a5ca2c2163 | ||
|
|
a17ddede53 | ||
|
|
7012005feb | ||
|
|
0ecaa59df6 | ||
|
|
309fdf422f | ||
|
|
9e88e4b940 | ||
|
|
7519884a5e | ||
|
|
852421315b | ||
|
|
33857d9aa7 | ||
|
|
ef41034e17 | ||
|
|
331269a186 | ||
|
|
c14f692b68 | ||
|
|
4247dc4271 | ||
|
|
7f945d2530 | ||
|
|
3dc9eded54 | ||
|
|
5c13267b47 | ||
|
|
e10c8c7234 | ||
|
|
052963f19e | ||
|
|
c7d7c6c608 | ||
|
|
6c4c097150 | ||
|
|
3eb4d5efdd | ||
|
|
ea95912bd5 | ||
|
|
d080a3a87b | ||
|
|
54b501913c | ||
|
|
7f7abe1c62 | ||
|
|
f1492f8889 | ||
|
|
4c6800f1ff | ||
|
|
b6c578ca77 | ||
|
|
f388512592 | ||
|
|
8574674c2d | ||
|
|
1b7cee9fcf | ||
|
|
12ee82808e | ||
|
|
5e964cf7e9 | ||
|
|
8768d03e57 | ||
|
|
75dfd725f4 | ||
|
|
ea343b634d | ||
|
|
41a2e0b1d5 | ||
|
|
e52e516f5c | ||
|
|
ea29f93fb6 | ||
|
|
eaa2f4cf04 | ||
|
|
954f729a30 | ||
|
|
d35e7da3e4 | ||
|
|
692f37daa2 | ||
|
|
e0f4855d0d | ||
|
|
a11784c615 | ||
|
|
922ec2c045 | ||
|
|
262c999e5c | ||
|
|
14a5b680d7 | ||
|
|
a316a95754 | ||
|
|
a81de493fe | ||
|
|
bdb3bc0bd7 | ||
|
|
8b2ae2d426 | ||
|
|
013a7322d2 | ||
|
|
e92f960a87 | ||
|
|
0b45ddfc79 | ||
|
|
897c64e0ba | ||
|
|
26558dfaca | ||
|
|
ff32a44345 | ||
|
|
d4925b7cdd | ||
|
|
3c81a44273 | ||
|
|
319b4dc841 | ||
|
|
71483b0fc4 | ||
|
|
366b84d3fa | ||
|
|
22dc68ff4e | ||
|
|
4903966bea | ||
|
|
f43c462907 | ||
|
|
490dc17571 | ||
|
|
b57a77c8f0 | ||
|
|
fe0e5e8b89 | ||
|
|
3340bea150 | ||
|
|
0e73697ea4 | ||
|
|
4fcbec49c9 | ||
|
|
01994d8c6a | ||
|
|
31de7fd0ee | ||
|
|
744c451927 | ||
|
|
148474e1ba | ||
|
|
d4765bcfec | ||
|
|
e4ea2035ff | ||
|
|
3a28bacf14 | ||
|
|
6ba7d208c8 | ||
|
|
102fdf3b18 | ||
|
|
1f539fc8be | ||
|
|
806f417e99 | ||
|
|
6c04184a9c | ||
|
|
22ff17aec9 | ||
|
|
b2650947a9 | ||
|
|
604bf0c485 | ||
|
|
b7bf3678e5 | ||
|
|
b0430f7eee | ||
|
|
7d3e440a47 | ||
|
|
6877261b9c | ||
|
|
eef45a6015 | ||
|
|
0aee431527 | ||
|
|
90a18186d9 | ||
|
|
38aea7c455 | ||
|
|
e272048f24 | ||
|
|
6aa9f208ee | ||
|
|
b749bf7b08 | ||
|
|
ff3daed4c6 | ||
|
|
e90e10bd26 | ||
|
|
c6a49b048f | ||
|
|
29af079a8f | ||
|
|
9bb6be8e60 | ||
|
|
226daa990f | ||
|
|
b8e3809452 | ||
|
|
47881f77d9 | ||
|
|
69d0a5286e | ||
|
|
ebdd78edea | ||
|
|
eff7c363d4 | ||
|
|
44cd482695 | ||
|
|
a801e0c5e9 | ||
|
|
722f836714 | ||
|
|
1dd62be4ef | ||
|
|
33f731e247 | ||
|
|
7cf139f856 | ||
|
|
fd28c939f5 | ||
|
|
1ab68172cb | ||
|
|
87e9ae5a3e | ||
|
|
c47a7d78fe | ||
|
|
b10b3a3434 | ||
|
|
9d4de4b295 | ||
|
|
2e5b123749 | ||
|
|
3a6eaa3ddd | ||
|
|
eb42d739cb | ||
|
|
e5d5bd5ec8 | ||
|
|
24166a4271 | ||
|
|
232149817e | ||
|
|
a286834eb5 | ||
|
|
c500616bb4 | ||
|
|
42faa2e75b | ||
|
|
b19cf35d28 | ||
|
|
4585c7b649 | ||
|
|
326a2a1877 | ||
|
|
8d057613f5 | ||
|
|
0b00c2ad76 | ||
|
|
310f56a9b3 | ||
|
|
0a94e740d2 | ||
|
|
3f3a503def | ||
|
|
0413865a3b | ||
|
|
98268a95c2 | ||
|
|
1110a78e06 | ||
|
|
6086f76d95 | ||
|
|
268eaaa9ca | ||
|
|
ad1612d84a | ||
|
|
0389a58f64 | ||
|
|
6ee2f334f6 | ||
|
|
a5afdf4e91 | ||
|
|
ecaa570ff3 | ||
|
|
1d2d1e6df7 | ||
|
|
c242f0079c | ||
|
|
727c533f93 | ||
|
|
5961b7d951 | ||
|
|
bbab069bcd | ||
|
|
1cf3b776d7 | ||
|
|
1aa2643c18 | ||
|
|
1150c04298 | ||
|
|
9c57ab5221 | ||
|
|
3e61b8c21a | ||
|
|
99bed645f3 | ||
|
|
51f5982205 | ||
|
|
b7a06dd0cf | ||
|
|
e071e4cdbf | ||
|
|
470b18786e | ||
|
|
8f21453e82 | ||
|
|
fb76917ec3 | ||
|
|
c53500812c | ||
|
|
5e6b9e578b | ||
|
|
f9c495a596 | ||
|
|
e4ec8cccfd | ||
|
|
3be350786d | ||
|
|
7cd43de5d5 | ||
|
|
f698a05d53 | ||
|
|
518a15934f | ||
|
|
48dbd079cf | ||
|
|
efa22715d5 | ||
|
|
0d88fcc758 | ||
|
|
353e04bddd | ||
|
|
0a6c03079c | ||
|
|
a0a4549045 | ||
|
|
69c79c5e0a | ||
|
|
1edf60362e | ||
|
|
739990c732 | ||
|
|
c9cfead9bc | ||
|
|
d37f493c6a | ||
|
|
b3153ae0fd | ||
|
|
7fc5b833aa | ||
|
|
d48d4ed8f9 | ||
|
|
f57a7009a3 | ||
|
|
6c4888d275 | ||
|
|
3820d08af8 | ||
|
|
bba2783aa4 | ||
|
|
f650308986 | ||
|
|
bd13181042 | ||
|
|
6daad10210 | ||
|
|
52f758c6e1 | ||
|
|
290a88fd90 | ||
|
|
423f54e95d | ||
|
|
9e46b5117d | ||
|
|
e8ff6f509b | ||
|
|
e7e777ec7b | ||
|
|
f74f932dcd | ||
|
|
7fafb25821 | ||
|
|
dd256be4ec | ||
|
|
d743804b1d | ||
|
|
f8951b44fc | ||
|
|
ec70670630 | ||
|
|
ee07921d42 | ||
|
|
5548494a44 | ||
|
|
7c8ad4aee4 | ||
|
|
12b4325435 | ||
|
|
241d02584a | ||
|
|
0f450fd9bf | ||
|
|
ce02c514cf | ||
|
|
322ab9d854 | ||
|
|
d40ee71a2c | ||
|
|
c81bb0f15d | ||
|
|
b7fda194c8 | ||
|
|
c37f41c171 | ||
|
|
ced8d2a45f | ||
|
|
c580c34a35 | ||
|
|
fdf312d9e1 | ||
|
|
44d8b549c4 | ||
|
|
928dd27043 | ||
|
|
4419051347 | ||
|
|
8cf88019e5 | ||
|
|
710971a0cd | ||
|
|
dc306dfcd0 | ||
|
|
e90520a5ec | ||
|
|
7805bd1e45 | ||
|
|
c1c55ca700 | ||
|
|
8e34d2fbbc | ||
|
|
61afb64dd7 | ||
|
|
aa2bc545db | ||
|
|
067f122b05 | ||
|
|
9d9bb68d50 | ||
|
|
af5abae558 | ||
|
|
c59caa5d7f | ||
|
|
0ea8705014 | ||
|
|
92409820fb | ||
|
|
98fc6c6adf | ||
|
|
771bc6a14d | ||
|
|
86c36f53e4 | ||
|
|
5c24089f9f | ||
|
|
516c8d79ad | ||
|
|
ff7a8cade1 | ||
|
|
7af4cdffee | ||
|
|
b06838b651 | ||
|
|
b3a4c21c4b | ||
|
|
855881094b | ||
|
|
82d02e923a | ||
|
|
d11d66fa90 | ||
|
|
f5507436f3 | ||
|
|
eeea33c7cb | ||
|
|
7883ca7657 | ||
|
|
8efb8b2f86 | ||
|
|
408a30c25b | ||
|
|
9b67aa537a | ||
|
|
5aabf87898 | ||
|
|
67dbdcd257 | ||
|
|
3d137995d8 | ||
|
|
e424e9328b | ||
|
|
214ecf605b | ||
|
|
7d06d0660d | ||
|
|
c34eddb82a | ||
|
|
9969606432 | ||
|
|
d8abdb7927 | ||
|
|
71a60795cf | ||
|
|
d07ce0b8f4 | ||
|
|
565bc70843 | ||
|
|
7924861810 | ||
|
|
08dd92b726 | ||
|
|
dca5dc4fce | ||
|
|
24f3637199 | ||
|
|
4dd95c1639 | ||
|
|
4724669bce | ||
|
|
292c334460 | ||
|
|
dafdf66ada | ||
|
|
38424af48e | ||
|
|
88a33990b7 | ||
|
|
7ce305e16f | ||
|
|
1d1ba8607e | ||
|
|
9f6385f763 | ||
|
|
a68b591029 | ||
|
|
711207743b | ||
|
|
a8a7bb3c99 | ||
|
|
228c118714 | ||
|
|
0b86402ce3 | ||
|
|
2295f7a92b | ||
|
|
8e03eefa9b | ||
|
|
53040dbe1d | ||
|
|
6d5b5ab44f | ||
|
|
0a18985e68 | ||
|
|
047aa7deef | ||
|
|
945ed3f7cb | ||
|
|
e29ea99d2c | ||
|
|
3b19aaf1d1 | ||
|
|
15a91278d6 | ||
|
|
cb602dd377 | ||
|
|
7e2f365c1c | ||
|
|
8425be0612 | ||
|
|
c0199a38fd | ||
|
|
d97a8c1934 | ||
|
|
7c36ee7955 | ||
|
|
55dde3531e | ||
|
|
c3a8ae1eb5 | ||
|
|
edc9560d36 | ||
|
|
37cfb93217 | ||
|
|
28ee40074a | ||
|
|
0ba4598ca2 | ||
|
|
ecb5b0fdeb | ||
|
|
6cf23f1fd1 | ||
|
|
b86f034c0b | ||
|
|
ce3d7f21b0 | ||
|
|
b38d5f3465 | ||
|
|
a5ad0b185c | ||
|
|
4f5e135992 | ||
|
|
50d83d2374 | ||
|
|
64381be91d | ||
|
|
f47494e5c8 | ||
|
|
32a105bac8 | ||
|
|
65b17c9d18 | ||
|
|
aef159b097 | ||
|
|
d29a6542de | ||
|
|
aef697e30a | ||
|
|
fca063e131 | ||
|
|
8a859044cb | ||
|
|
895e3878f9 | ||
|
|
b2556e3306 | ||
|
|
eebc24086b | ||
|
|
94bbc44960 | ||
|
|
78712541f0 | ||
|
|
2b4bdf39fb | ||
|
|
a8faaef54e | ||
|
|
44bad8e093 | ||
|
|
a988ab84f9 | ||
|
|
85e2013639 | ||
|
|
1978801561 | ||
|
|
95a4da71cb | ||
|
|
f13a65ca85 | ||
|
|
e87be44134 | ||
|
|
fb8dfa02f2 | ||
|
|
67e0ca28a9 | ||
|
|
7438db0a7d | ||
|
|
b47f064115 | ||
|
|
d9afc47993 | ||
|
|
fcee108863 | ||
|
|
5a74b8066f | ||
|
|
809a87ce61 | ||
|
|
c2c05816f3 | ||
|
|
cc4fff0ae5 | ||
|
|
be537f3a24 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
* @epoberezkin @efim-poberezkin
|
||||
* @epoberezkin @jr-simplex
|
||||
|
||||
62
.github/workflows/build.yml
vendored
62
.github/workflows/build.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- v4
|
||||
- stable
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
@@ -32,6 +32,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: true
|
||||
files: |
|
||||
LICENSE
|
||||
fail_on_unmatched_files: true
|
||||
@@ -49,24 +50,15 @@ jobs:
|
||||
include:
|
||||
- os: ubuntu-20.04
|
||||
cache_path: ~/.stack
|
||||
stack_args: "--test"
|
||||
artifact_rel_path: /bin/simplex-chat
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
- os: ubuntu-18.04
|
||||
cache_path: ~/.stack
|
||||
stack_args: "--test"
|
||||
artifact_rel_path: /bin/simplex-chat
|
||||
asset_name: simplex-chat-ubuntu-18_04-x86-64
|
||||
- os: macos-latest
|
||||
cache_path: ~/.stack
|
||||
stack_args: "--test"
|
||||
artifact_rel_path: /bin/simplex-chat
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
# TODO enable tests for windows once fixed (remove stack_args altogether)
|
||||
- os: windows-latest
|
||||
cache_path: C:/sr
|
||||
stack_args: ""
|
||||
artifact_rel_path: /bin/simplex-chat.exe
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
steps:
|
||||
- name: Clone project
|
||||
@@ -85,17 +77,51 @@ jobs:
|
||||
path: ${{ matrix.cache_path }}
|
||||
key: ${{ matrix.os }}-${{ hashFiles('stack.yaml') }}
|
||||
|
||||
- name: Build & test
|
||||
id: build_test
|
||||
run: |
|
||||
stack build ${{ matrix.stack_args }}
|
||||
echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)"
|
||||
# / Unix
|
||||
|
||||
- name: Upload binaries to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- name: Unix build
|
||||
id: unix_build
|
||||
if: matrix.os != 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
stack build --test
|
||||
echo "::set-output name=local_install_root::$(stack path --local-install-root)"
|
||||
|
||||
- name: Unix upload binary 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.build_test.outputs.LOCAL_INSTALL_ROOT }}${{ matrix.artifact_rel_path }}
|
||||
file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
# Unix /
|
||||
|
||||
# / Windows
|
||||
|
||||
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
|
||||
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
|
||||
# * So we're running a separate set of actions for Windows build
|
||||
|
||||
# TODO run tests on Windows
|
||||
- name: Windows build
|
||||
id: windows_build
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: cmd
|
||||
run: |
|
||||
stack build
|
||||
stack path --local-install-root > tmp_file
|
||||
set /p local_install_root= < tmp_file
|
||||
echo ::set-output name=local_install_root::%local_install_root%
|
||||
|
||||
- name: Windows upload binary 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_build.outputs.local_install_root }}\bin\simplex-chat.exe
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
# Windows /
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -5,12 +5,6 @@
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
@@ -42,9 +36,9 @@ cabal.project.local~
|
||||
.ghc.environment.*
|
||||
stack.yaml.lock
|
||||
|
||||
# Idris
|
||||
*.ibc
|
||||
|
||||
# chat database
|
||||
# Chat database
|
||||
*.db
|
||||
*.db.bak
|
||||
|
||||
# Temporary test files
|
||||
tests/tmp
|
||||
|
||||
90
PRIVACY.md
Normal file
90
PRIVACY.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# SimpleX Chat Terms & Privacy Policy
|
||||
|
||||
SimpleX Chat is the first chat platform that is 100% private by design - not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we do not have access to your connections graph.
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
### Information you provide
|
||||
|
||||
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||
|
||||
Messages. SimpleX Chat cannot decrypt or otherwise access the content or size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are temporarily offline. Your message history is stored only on your own devices.
|
||||
|
||||
Connections with other users. When you create a connection with another user, two messaging queues are created on our servers (we use separate queues for direct and response messages, that can be on two different servers), or on the servers that you configured in the app, in case it allows such configuration. At the time of updating this document only our terminal app allows configuring the servers, our mobile apps will allow such configuration in the near future. Our servers do not store information about which queues are linked to your profile on the device, and they do not have any information in common that allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of unique encryption keys, different for each queue, and separate for sender and recipient of the messages that are transmitted through the queue.
|
||||
|
||||
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
|
||||
|
||||
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support via chat, when it is possible.
|
||||
|
||||
### Information we may share
|
||||
|
||||
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
|
||||
|
||||
We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
|
||||
|
||||
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
|
||||
|
||||
- To meet any applicable law, regulation, legal process or enforceable governmental request.
|
||||
- To enforce applicable Terms, including investigation of potential violations.
|
||||
- To detect, prevent, or otherwise address fraud, security, or technical issues.
|
||||
- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
|
||||
|
||||
### Updates
|
||||
|
||||
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||
|
||||
Please also read our Terms of Service.
|
||||
|
||||
If you have questions about our Privacy Policy please contact us at chat@simplex.chat.
|
||||
|
||||
## Terms of Service
|
||||
|
||||
You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
||||
|
||||
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
|
||||
|
||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||
|
||||
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per users - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
||||
|
||||
**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners.
|
||||
|
||||
**Software**. You agree to downloading and installing updates to our Services when they are available; they would only be automatic if you configure your devices in this way.
|
||||
|
||||
**Traffic and device costs**. You are solely responsible for the traffic and device costs on which you use our Services, and any associated taxes.
|
||||
|
||||
**Legal and acceptable usage**. You agree to use our Services only for legal and acceptable purposes. You will not use (or assist others in using) our Services in ways that: 1) violate or infringe the rights of SimpleX Chat, our users, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam.
|
||||
|
||||
**Damage to SimpleX Chat**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Services in unauthorized manners, or in ways that harm SimpleX Chat, our Services, or systems. For example, you must not 1) access our Services or systems without authorization, other than by using the apps; 2) disrupt the integrity or performance of our Services; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Services.
|
||||
|
||||
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
|
||||
|
||||
**Storing the messages on the device**. Currently the messages are stored in the database on your device without encryption. It means that if you make a backup of the app and store it unecrypted, the backup provider may be able to access the messages.
|
||||
|
||||
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
|
||||
|
||||
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
|
||||
|
||||
**Your Rights**. You own the mesasges and information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices.
|
||||
|
||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 licence](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
||||
|
||||
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
|
||||
|
||||
**Disclaimers**. YOU USE OUR SERVICES AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR SERVICES ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR SERVICES WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR SERVICES WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR SERVICES. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
|
||||
|
||||
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR TERMS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
|
||||
**Availability**. Our Services may be interrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Services, including certain features and the support for certain devices and platforms, at any time.
|
||||
|
||||
**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Terms, us, or our Services in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Terms, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat and you, without regard to conflict of law provisions.
|
||||
|
||||
**Changes to the terms**. SimpleX Chat may update the Terms from time to time. Your continued use of our Services confirms your acceptance of our updated Terms and supersedes any prior Terms. You will comply with all applicable export control and trade sanctions laws. Our Terms cover the entire agreement between you and SimpleX Chat regarding our Services. If you do not agree with our Terms, you should stop using our Services.
|
||||
|
||||
**Enforcing the terms**. If we fail to enforce any of our Terms, that does not mean we waive the right to enforce them. If any provision of the Terms is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Terms and shall not affect the enforceability of the remaining provisions. Our Services are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Services in any country. If you have specific questions about these Terms, please contact us at chat@simplex.chat.
|
||||
|
||||
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
|
||||
|
||||
Updated March 1, 2022
|
||||
452
README.md
452
README.md
@@ -1,347 +1,179 @@
|
||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||
|
||||
# SimpleX Chat
|
||||
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
||||
|
||||
**The world's most private and secure chat** - open-source, decentralized, and without global identities of any kind.
|
||||
|
||||
[](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
|
||||
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://twitter.com/simplexchat)
|
||||
[](https://twitter.com/SimpleXChat)
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
|
||||
SimpleX chat prototype is a thin terminal UI on top of [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker that uses [SMP protocols](https://github.com/simplex-chat/simplexmq/blob/master/protocol). The motivation for SimpleX chat is [presented here](./simplex.md). See [simplex.chat](https://simplex.chat) website for chat demo and the explanations of the system and how SMP protocol works.
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||
|
||||
[](https://play.google.com/store/apps/details?id=chat.simplex.app)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
|
||||
|
||||
**NEW in v0.5.4: [messages persistence](#access-chat-history)**
|
||||
- 🖲 Protects your messages and metadata - who you talk to and when.
|
||||
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
|
||||
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
|
||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
|
||||
|
||||
**NEW in v0.5.0: [user contact addresses](#user-contact-addresses-alpha)**
|
||||
## Contents
|
||||
|
||||
**Please note**: v0.5.0 of SimpleX Chat works with the same database, but the connection links are not compatible with the prior versions - please ask all your contacts to upgrade!
|
||||
- [Why privacy matters](#why-privacy-matters)
|
||||
- [SimpleX approach to privacy and security](#simplex-approach-to-privacy-and-security)
|
||||
- [Complete privacy](#complete-privacy-of-your-identity-profile-contacts-and-metadata)
|
||||
- [Protection against spam and abuse](#the-best-protection-against-spam-and-abuse)
|
||||
- [Ownership and security of your data](#complete-ownership-control-and-security-of-your-data)
|
||||
- [Users own SimpleX network](#users-own-simplex-network)
|
||||
- [Frequently asked questions](#frequently-asked-questions)
|
||||
- [News and updates](#news-and-updates)
|
||||
- [Make a private connection](#make-a-private-connection)
|
||||
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||
- [SimpleX Platform design](#simplex-platform-design)
|
||||
- [For developers](#for-developers)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Disclaimer, License](#disclaimer)
|
||||
|
||||
### :zap: Quick installation
|
||||
## Why privacy matters
|
||||
|
||||
Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even if there is nothing to hide.
|
||||
|
||||
One of the most shocking stories is the experience of [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the 10 years prior to the attacks.
|
||||
|
||||
It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with.
|
||||
|
||||
## SimpleX approach to privacy and security
|
||||
|
||||
### Complete privacy of your identity, profile, contacts and metadata
|
||||
|
||||
**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - not even random numbers. This protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. [Read more](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata).
|
||||
|
||||
### The best protection against spam and abuse
|
||||
|
||||
As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. [Read more](./docs/SIMPLEX.md#the-best-protection-against-spam-and-abuse).
|
||||
|
||||
### Complete ownership, control and security of your data
|
||||
|
||||
SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received. [Read more](./docs/SIMPLEX.md#complete-ownership-control-and-security-of-your-data).
|
||||
|
||||
### Users own SimpleX network
|
||||
|
||||
You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. [Read more](./docs/SIMPLEX.md#users-own-simplex-network).
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release annoucement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
|
||||
|
||||
2. _Why should I not just use Signal?_ This [post](https://github.com/dessalines/essays/blob/master/why_not_signal.md) shows why Signal cannot be considered a private messenger. Signal is a centralised platform that uses phone numbers to identify its users and their contacts.
|
||||
|
||||
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identites?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
|
||||
|
||||
## News and updates
|
||||
|
||||
[Jun 4, 2022. v2.2: the new Privacy and Security settings](./blog/20220604-simplex-chat-new-privacy-security-settings.md)
|
||||
|
||||
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
|
||||
|
||||
[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md)
|
||||
|
||||
[Jan 12, 2022. SimpleX v1 released: the only messaging and application platform without user identities](./20220112-simplex-chat-v1-released.md)
|
||||
|
||||
[All updates](./blog)
|
||||
|
||||
## Make a private connection
|
||||
|
||||
You need to share a link or scan a QR code (in person or during a video call) to make a connection and start messaging.
|
||||
|
||||
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
||||
|
||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/conversation.png" alt="Make a private connection" width="594" height="360">
|
||||
|
||||
## :zap: Quick installation of a terminal app
|
||||
|
||||
```sh
|
||||
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
|
||||
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash
|
||||
```
|
||||
|
||||
Once the chat client is installed, simply run `simplex-chat` from your terminal.
|
||||
|
||||
### :wave: Welcome
|
||||
|
||||
**We are building the world's most private and secure chat**. If you would like to support it, you can do so in the following ways:
|
||||
|
||||
- 🌟 **Star it on GitHub** - it helps us raise the visibility of the project.
|
||||
|
||||
- **Install the chat and try it out** - if you spot a bug, please [raise an issue](https://github.com/simplex-chat/simplex-chat/issues).
|
||||
|
||||
- :speech_balloon: **Spread the word** - terminal chat is an [early-stage product](#disclaimer) while we stabilize the protocol - you can invite your friends for some fun chat inside your terminal. We're using it right inside our IDEs as we are coding it 👨💻
|
||||
|
||||
- **Make a donation** via [opencollective](https://opencollective.com/simplex-chat) - every donation helps, however large or small!
|
||||
|
||||
- **Make a contribution to the project** - we're constantly moving the project forward and there are always lots of things to do!
|
||||
|
||||
We appreciate all the help from our contributors, thank you!
|
||||
|
||||

|
||||
|
||||
## Table of contents
|
||||
Read more about [installing and using the terminal app](./docs/CLI.md).
|
||||
|
||||
- [Disclaimer](#disclaimer)
|
||||
- [Network topology](#network-topology)
|
||||
- [Terminal chat features](#terminal-chat-features)
|
||||
- [Installation](#🚀-installation)
|
||||
- [Download chat client](#download-chat-client)
|
||||
- [Linux and MacOS](#linux-and-macos)
|
||||
- [Troubleshooting on Unix](#troubleshooting-on-unix)
|
||||
- [Windows](#windows)
|
||||
- [Build from source](#build-from-source)
|
||||
- [Using Docker](#using-docker)
|
||||
- [Using Haskell stack](#using-haskell-stack)
|
||||
- [Usage](#usage)
|
||||
- [Running the chat client](#running-the-chat-client)
|
||||
- [How to use SimpleX chat](#how-to-use-simplex-chat)
|
||||
- [Groups](#groups)
|
||||
- [Sending files](#sending-files)
|
||||
- [User contact addresses](#user-contact-addresses-alpha)
|
||||
- [Access chat history](#access-chat-history)
|
||||
- [Roadmap](#Roadmap)
|
||||
- [License](#license)
|
||||
## SimpleX Platform design
|
||||
|
||||
## Disclaimer
|
||||
SimpleX is a client-server network with a unique network topology that uses redundant, disposable message relay nodes to asynchronously pass messages via unidirectional (simplex) message queues, providing recipient and sender anonymity.
|
||||
|
||||
This is WIP implementation of SimpleX Chat that implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks.
|
||||
Unlike P2P networks, all messages are passed through one or several server nodes, that do not even need to have persistence. In fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records. SimpleX provides better metadata protection than P2P designs, as no global participant identifiers are used to deliver messages, and avoids [the problems of P2P networks](./docs/SIMPLEX.md#comparison-with-p2p-messaging-protocols).
|
||||
|
||||
If you expect software to be reliable most of the time, then this is probably not ready for you yet. We use it ourselves for terminal chat and it seems to work most of the time - we would really appreciate if you try SimpleX Chat and give us your feedback!
|
||||
Unlike federated networks, the server nodes **do not have records of the users**, **do not communicate with each other** and **do not store messages** after they are delivered to the recipients. There is no way to discover the full list of servers participating in SimpleX network. This design avoids the problem of metadata visibility that all federated networks have and better protects from the network-wide attacks.
|
||||
|
||||
> :warning: **Please note:** The main differentiation of SimpleX network is the approach to internet message routing rather than encryption; for that reason no sufficient attention was paid to either TCP transport level encryption or to E2E encryption protocols - they are implemented in an ad hoc way based on RSA and AES algorithms. See [SMP protocol](https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a) on TCP transport encryption protocol (AEAD-GCM scheme, with an AES key negotiation based on RSA key hash known to the client in advance) and [this section](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2021-01-26-crypto.md#e2e-encryption) on E2E encryption protocol (an ad hoc hybrid scheme a la PGP). These protocols will change in a consumer ready version to something more robust.
|
||||
Only the client devices have information about users, their contacts and groups.
|
||||
|
||||
## Network topology
|
||||
See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
|
||||
|
||||
SimpleX is a decentralized client-server network that uses redundant, disposable nodes to asynchronously pass messages via message queues, providing receiver and sender anonymity.
|
||||
## For developers
|
||||
|
||||
Unlike P2P networks, all messages are passed through one or several (for redundancy) servers, that do not even need to have persistence (in fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records) - it provides better metadata protection than P2P designs, as no global participant ID is required, and avoids many [problems of P2P networks](https://github.com/simplex-chat/simplex-chat/blob/master/simplex.md#comparison-with-p2p-messaging-protocols).
|
||||
We plan that the SimpleX platform will grow into the platform supporting any distributed Internet application. This will allow you to build any service that people can access via chat, with custom web-based UI widgets that anybody with basic HTML/CSS/JavaScript knowledge can create in a few hours.
|
||||
|
||||
Unlike federated networks, the participating server nodes **do not have records of the users**, **do not communicate with each other**, **do not store messages** after they are delivered to the recipients, and there is no way to discover the full list of participating servers. SimpleX network avoids the problem of metadata visibility that federated networks suffer from and better protects the network, as servers do not communicate with each other. Each server node provides unidirectional "dumb pipes" to the users, that do authorization without authentication, having no knowledge of the the users or their contacts. Each queue is assigned two RSA keys - one for receiver and one for sender - and each queue access is authorized with a signature created using a respective key's private counterpart.
|
||||
You already can:
|
||||
|
||||
The routing of messages relies on the knowledge of client devices how user contacts and groups map at any given moment of time to these disposable queues on server nodes.
|
||||
- use SimpleX Chat library to integrate chat functionality into your apps.
|
||||
- use SimpleX Chat bot templates in Haskell to build your own chat bot services (TypeScript SDK is coming soon).
|
||||
|
||||
## Terminal chat features
|
||||
|
||||
- 1-to-1 chat with multiple people in the same terminal window.
|
||||
- Group messaging.
|
||||
- Sending files to contacts and groups.
|
||||
- User contact addresses - establish connections via multiple-use contact links.
|
||||
- Messages persisted in a local SQLite database.
|
||||
- Auto-populated recipient name - just type your messages to reply to the sender once the connection is established.
|
||||
- Demo SMP servers available and pre-configured in the app - or you can [deploy your own server](https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent).
|
||||
- No global identity or any names visible to the server(s), ensuring full privacy of your contacts and conversations.
|
||||
- E2E encryption, with RSA public key that has to be passed out-of-band (see [How to use SimpleX chat](#how-to-use-simplex-chat)).
|
||||
- Message signing and verification with automatically generated RSA keys.
|
||||
- Message integrity validation (via including the digests of the previous messages).
|
||||
- Authentication of each command/message by SMP servers with automatically generated RSA key pairs.
|
||||
- TCP transport encryption using SMP transport protocol.
|
||||
|
||||
RSA keys are not used as identity, they are randomly generated for each contact.
|
||||
|
||||
<a name="🚀-installation"></a>
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### Download chat client
|
||||
|
||||
#### Linux and MacOS
|
||||
|
||||
To **install** or **update** `simplex-chat`, you should run the install script. To do that, use the following cURL or Wget command:
|
||||
|
||||
```sh
|
||||
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
|
||||
```
|
||||
|
||||
```sh
|
||||
wget -qO- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
|
||||
```
|
||||
|
||||
Once the chat client downloads, you can run it with `simplex-chat` command in your terminal.
|
||||
|
||||
Alternatively, you can manually download the chat binary for your system from the [latest stable release](https://github.com/simplex-chat/simplex-chat/releases) and make it executable as shown below.
|
||||
|
||||
```sh
|
||||
chmod +x <binary>
|
||||
mv <binary> ~/.local/bin/simplex-chat
|
||||
```
|
||||
|
||||
(or any other preferred location on `PATH`).
|
||||
|
||||
On MacOS you also need to [allow Gatekeeper to run it](https://support.apple.com/en-us/HT202491).
|
||||
|
||||
##### Troubleshooting on Unix
|
||||
|
||||
If you get `simplex-chat: command not found` when executing the downloaded binary, you need to add the directory containing it to the [`PATH` variable](https://man7.org/linux/man-pages/man7/environ.7.html) (find "PATH" in page). To modify `PATH` for future sessions, put `PATH="$PATH:/path/to/dir"` in `~/.profile`, or in `~/.bash_profile` if that's what you have. See [this answer](https://unix.stackexchange.com/a/26059) for the detailed explanation on the appropriate place to define environment variables for `bash` and other shells.
|
||||
|
||||
For example, if you followed the previous instructions, open `~/.profile` for editing:
|
||||
|
||||
```sh
|
||||
vi ~/.profile
|
||||
```
|
||||
|
||||
And add the following line to the end:
|
||||
|
||||
```sh
|
||||
PATH="$PATH:$HOME/.local/bin"
|
||||
```
|
||||
|
||||
Note that this will not automatically update your `PATH` for the remainder of the session. To do this, you should run:
|
||||
|
||||
```sh
|
||||
source ~/.profile
|
||||
```
|
||||
|
||||
Or restart your terminal to start a new session.
|
||||
|
||||
#### Windows
|
||||
|
||||
```sh
|
||||
move <binary> %APPDATA%/local/bin/simplex-chat.exe
|
||||
```
|
||||
|
||||
### Build from source
|
||||
|
||||
#### Using Docker
|
||||
|
||||
On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs):
|
||||
|
||||
```shell
|
||||
$ git clone git@github.com:simplex-chat/simplex-chat.git
|
||||
$ cd simplex-chat
|
||||
$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
|
||||
```
|
||||
|
||||
> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
|
||||
|
||||
#### Using Haskell stack
|
||||
|
||||
Install [Haskell stack](https://docs.haskellstack.org/en/stable/README/):
|
||||
|
||||
```shell
|
||||
curl -sSL https://get.haskellstack.org/ | sh
|
||||
```
|
||||
|
||||
and build the project:
|
||||
|
||||
```shell
|
||||
$ git clone git@github.com:simplex-chat/simplex-chat.git
|
||||
$ cd simplex-chat
|
||||
$ stack install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Running the chat client
|
||||
|
||||
To start the chat client, run `simplex-chat` from the terminal. If you get `simplex-chat: command not found`, see [Troubleshooting on Unix](#troubleshooting-on-unix).
|
||||
|
||||
By default, app data directory is created in the home directory (`~/.simplex`, or `%APPDATA%/simplex` on Windows), and two SQLite database files `simplex.chat.db` and `simplex.agent.db` are initialized in it.
|
||||
|
||||
To specify a different file path prefix for the database files use `-d` command line option:
|
||||
|
||||
```shell
|
||||
$ simplex-chat -d alice
|
||||
```
|
||||
|
||||
Running above, for example, would create `alice.chat.db` and `alice.agent.db` database files in current directory.
|
||||
|
||||
Default SMP servers are hosted on Linode (London, UK and Fremont, CA) - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/Options.hs#L40). Base-64 encoded string after server host is the transport key digest.
|
||||
|
||||
If you deployed your own SMP server(s) you can configure client via `-s` option:
|
||||
|
||||
```shell
|
||||
$ simplex-chat -s smp.example.com:5223#KXNE1m2E1m0lm92WGKet9CL6+lO742Vy5G6nsrkvgs8=
|
||||
```
|
||||
|
||||
The base-64 encoded string in server address is the digest of RSA transport handshake key that the server will generate on the first run and output its digest.
|
||||
|
||||
You can still talk to people using default or any other server - it only affects the location of the message queue when you initiate the connection (and the reply queue can be on another server, as set by the other party's client).
|
||||
|
||||
Run `simplex-chat -h` to see all available options.
|
||||
|
||||
### How to use SimpleX chat
|
||||
|
||||
Once you have started the chat, you will be prompted to specify your "display name" and an optional "full name" to create a local chat profile. Your display name is an alias for your contacts to refer to you by - it is not unique and does not serve as a global identity. If some of your contacts chose the same display name, the chat client adds a numeric suffix to their local display name.
|
||||
|
||||
The diagram below shows how to connect and message a contact:
|
||||
|
||||
<div align="center">
|
||||
<img align="center" src="images/how-to-use-simplex.svg">
|
||||
</div>
|
||||
|
||||
Once you've set up your local profile, enter `/c` (for `/connect`) to create a new connection and generate an invitation. Send this invitation to your contact via any other channel.
|
||||
|
||||
You are able to create multiple invitations by entering `/connect` multiple times and sending these invitations to the corresponding contacts you'd like to connect with.
|
||||
|
||||
The invitation has the format `smp::<server>::<queue_id>::<rsa_public_key_for_this_queue_only>`. The invitation can only be used once and even if this is intercepted, the attacker would not be able to use it to send you the messages via this queue once your contact confirms that the connection is established.
|
||||
|
||||
The contact who received the invitation should enter `/c <invitation>` to accept the connection. This establishes the connection, and both parties are notified.
|
||||
|
||||
They would then use `@<name> <message>` commands to send messages. You may also just start typing a message to send it to the contact that was the last.
|
||||
|
||||
Use `/help` in chat to see the list of available commands.
|
||||
|
||||
### Groups
|
||||
|
||||
To create a group use `/g <group>`, then add contacts to it with `/a <group> <name>`. You can then send messages to the group by entering `#<group> <message>`. Use `/help groups` for other commands.
|
||||
|
||||

|
||||
|
||||
> **Please note**: the groups are not stored on any server, they are maintained as a list of members in the app database to whom the messages will be sent.
|
||||
|
||||
### Sending files
|
||||
|
||||
You can send a file to your contact with `/f @<contact> <file_path>` - the recipient will have to accept it before it is sent. Use `/help files` for other commands.
|
||||
|
||||

|
||||
|
||||
You can send files to a group with `/f #<group> <file_path>`.
|
||||
|
||||
### User contact addresses (alpha)
|
||||
|
||||
As an alternative to one-time invitation links, you can create a long-term address with `/ad` (for `/address`). The created address can then be shared via any channel, and used by other users as a link to make a contact request with `/c <user_contact_address>`.
|
||||
|
||||
You can accept or reject incoming requests with `/ac <name>` and `/rc <name>` commands.
|
||||
|
||||
User address is "long-term" in a sense that it is a multiple-use connection link - it can be used until it is deleted by the user, in which case all established connections would still remain active (unlike how it works with email, when changing the address results in people not being able to message you).
|
||||
|
||||
Use `/help address` for other commands.
|
||||
|
||||
> :warning: **Please note:** This is an "alpha" feature - at the moment there is nothing to prevent someone who has obtained this address from spamming you with connection requests; countermeasures will be added soon! (In the short term, you can simply delete the long-term address you created if it starts getting abused.)
|
||||
|
||||

|
||||
|
||||
### Access chat history
|
||||
|
||||
SimpleX chat stores all your contacts and conversations in a local SQLite database, making it private and portable by design, owned and controlled by user.
|
||||
|
||||
You can view and search your chat history by querying your database:
|
||||
|
||||
```
|
||||
sqlite3 ~/.simplex/simplex.chat.db
|
||||
```
|
||||
|
||||
Now you can run queries against `direct_messages`, `group_messages` and `all_messages` (or their simpler alternatives `direct_messages_plain`, `group_messages_plain` and `all_messages_plain`), for example:
|
||||
|
||||
```sql
|
||||
-- you can put these or your preferred settings into ~/.sqliterc to persist across sqlite3 client sessions
|
||||
.mode column
|
||||
.headers on
|
||||
|
||||
-- simple views into direct, group and all_messages with user's messages deduplicated for group and all_messages
|
||||
-- only 'x.msg.new' ("new message") chat events - filters out service events
|
||||
-- msg_sent is 0 for received, 1 for sent
|
||||
select * from direct_messages_plain;
|
||||
select * from group_messages_plain;
|
||||
select * from all_messages_plain;
|
||||
|
||||
-- query other details of your chat history with regular SQL
|
||||
select * from direct_messages where msg_sent = 1 and chat_msg_event = 'x.file'; -- files you offered for sending
|
||||
select * from direct_messages where msg_sent = 0 and contact = 'catherine' and msg_body like '%cats%'; -- everything catherine sent related to cats
|
||||
select * from group_messages where group_name = 'team' and contact = 'alice'; -- all correspondence with alice in #team
|
||||
|
||||
-- aggregate your chat data
|
||||
select contact_or_group, num_messages from (
|
||||
select contact as contact_or_group, count(1) as num_messages from direct_messages_plain group by contact
|
||||
union
|
||||
select group_name as contact_or_group, count(1) as num_messages from group_messages_plain group by group_name
|
||||
) order by num_messages desc;
|
||||
```
|
||||
|
||||
**Convenience queries**
|
||||
|
||||
Get all messages from today (`chat_dt` is in UTC):
|
||||
|
||||
```sql
|
||||
select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt;
|
||||
```
|
||||
|
||||
Get overnight messages in the morning:
|
||||
|
||||
```sql
|
||||
select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt;
|
||||
```
|
||||
|
||||
> **Please note:** SQLite foreign key constraints are disabled by default, and must be **[enabled separately for each database connection](https://sqlite.org/foreignkeys.html#fk_enable)**. The latter can be achieved by running `PRAGMA foreign_keys = ON;` command on an open database connection. By running data altering queries without enabling foreign keys prior to that, you may risk putting your database in an inconsistent state.
|
||||
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. Mobile and desktop apps (in progress).
|
||||
2. SMP protocol improvements:
|
||||
- SMP queue redundancy and rotation.
|
||||
- Message delivery confirmation.
|
||||
- Support multiple devices.
|
||||
3. Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||
- keep all your contacts and groups even if you lose the domain.
|
||||
- the server doesn't have information about your contacts and groups.
|
||||
4. Media server to optimize sending large files to groups.
|
||||
5. Channels server for large groups and broadcast channels.
|
||||
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
|
||||
- ✅ Terminal (console) client with groups and files support.
|
||||
- ✅ One-click SimpleX server deployment on Linode.
|
||||
- ✅ End-to-end encryption using double-ratchet protocol with additional encryption layer.
|
||||
- ✅ Mobile apps v1 for Android and iOS.
|
||||
- ✅ Private instant notifications for Android using background service.
|
||||
- ✅ Haskell chat bot templates.
|
||||
- ✅ v2.0 - supporting images and files in mobile apps.
|
||||
- ✅ Manual chat history deletion.
|
||||
- 🚀 End-to-end encrypted audio and video calls via the mobile apps (enable via Experimental Features).
|
||||
- 🏗 Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress).
|
||||
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
|
||||
- 🏗 Chat database portability and encryption.
|
||||
- Groups support for mobile apps.
|
||||
- Disappearing messages, with mutual agreement.
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
- SMP protocol improvements:
|
||||
- SMP queue redundancy and rotation.
|
||||
- Message delivery confirmation.
|
||||
- Supporting the same profile on multiple devices.
|
||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||
- keep all your contacts and groups even if you lose the domain.
|
||||
- the server doesn't have information about your contacts and groups.
|
||||
- Media server to optimize sending large files to groups.
|
||||
- Channels server for large groups and broadcast channels.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed and had many improvements in v1.0.0; we are currently arranging for the independent implementation audit.
|
||||
|
||||
You are likely to discover some bugs - we would really appreciate if you use it and let us know anything that needs to be fixed or improved.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL v3](./LICENSE)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||
|
||||
[](https://play.google.com/store/apps/details?id=chat.simplex.app)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
|
||||
|
||||
17
apps/android/.gitignore
vendored
Normal file
17
apps/android/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/misc.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
1
apps/android/.idea/.name
generated
Normal file
1
apps/android/.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
SimpleX
|
||||
149
apps/android/.idea/codeStyles/Project.xml
generated
Normal file
149
apps/android/.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,149 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<ComposeCustomCodeStyleSettings>
|
||||
<option name="USE_CUSTOM_FORMATTING_FOR_MODIFIERS" value="false" />
|
||||
</ComposeCustomCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="SPACE_BEFORE_EXTEND_COLON" value="false" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="3" />
|
||||
<option name="WRAP_EXPRESSION_BODY_FUNCTIONS" value="0" />
|
||||
<option name="WRAP_ELVIS_EXPRESSIONS" value="0" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
<option name="RIGHT_MARGIN" value="140" />
|
||||
<option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
|
||||
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
|
||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
||||
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="CALL_PARAMETERS_WRAP" value="0" />
|
||||
<option name="METHOD_PARAMETERS_WRAP" value="0" />
|
||||
<option name="EXTENDS_LIST_WRAP" value="0" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="0" />
|
||||
<option name="ASSIGNMENT_WRAP" value="0" />
|
||||
<option name="METHOD_ANNOTATION_WRAP" value="0" />
|
||||
<option name="CLASS_ANNOTATION_WRAP" value="0" />
|
||||
<option name="FIELD_ANNOTATION_WRAP" value="0" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
6
apps/android/.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
6
apps/android/.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
6
apps/android/.idea/compiler.xml
generated
Normal file
6
apps/android/.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
</component>
|
||||
</project>
|
||||
19
apps/android/.idea/gradle.xml
generated
Normal file
19
apps/android/.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
20
apps/android/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
20
apps/android/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
apps/android/.idea/vcs.xml
generated
Normal file
6
apps/android/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
1
apps/android/app/.gitignore
vendored
Normal file
1
apps/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
115
apps/android/app/build.gradle
Normal file
115
apps/android/app/build.gradle
Normal file
@@ -0,0 +1,115 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 32
|
||||
|
||||
defaultConfig {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 36
|
||||
versionName "2.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi"
|
||||
freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets"
|
||||
freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi"
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file('src/main/cpp/CMakeLists.txt')
|
||||
version '3.10.2'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion compose_version
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_version"
|
||||
implementation "androidx.compose.material:material:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
||||
implementation 'androidx.activity:activity-compose:1.4.0'
|
||||
implementation 'androidx.fragment:fragment:1.4.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||
implementation "androidx.navigation:navigation-compose:2.4.1"
|
||||
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
||||
implementation 'androidx.webkit:webkit:1.4.0'
|
||||
|
||||
def work_version = "2.7.1"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.work:work-multiprocess:$work_version"
|
||||
|
||||
def camerax_version = "1.1.0-beta01"
|
||||
implementation "androidx.camera:camera-core:${camerax_version}"
|
||||
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
||||
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
||||
implementation "androidx.camera:camera-view:${camerax_version}"
|
||||
|
||||
//Barcode
|
||||
implementation 'org.boofcv:boofcv-android:0.40.1'
|
||||
implementation 'org.boofcv:boofcv-core:0.40.1'
|
||||
|
||||
//Camera Permission
|
||||
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
|
||||
|
||||
// Link Previews
|
||||
implementation 'org.jsoup:jsoup:1.13.1'
|
||||
|
||||
// Biometric authentication
|
||||
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
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:$compose_version"
|
||||
}
|
||||
21
apps/android/app/proguard-rules.pro
vendored
Normal file
21
apps/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,22 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("chat.simplex.app", appContext.packageName)
|
||||
}
|
||||
}
|
||||
104
apps/android/app/src/main/AndroidManifest.xml
Normal file
104
apps/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="chat.simplex.app">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.VIDEO_CAPTURE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<application
|
||||
android:name="SimplexApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/icon"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
|
||||
<!-- Main activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- open simplex:/ connection URI -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="simplex" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="simplex.chat" />
|
||||
<data android:pathPrefix="/invitation" />
|
||||
<data android:pathPrefix="/contact" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".views.call.IncomingCallActivity"
|
||||
android:showOnLockScreen="true"/>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="chat.simplex.app.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- SimplexService foreground service -->
|
||||
<service
|
||||
android:name=".SimplexService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false"></service>
|
||||
|
||||
<!-- SimplexService restart on reboot -->
|
||||
<receiver
|
||||
android:name=".SimplexService$StartReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- SimplexService restart on destruction -->
|
||||
<receiver
|
||||
android:name=".SimplexService$AutoRestartReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
3
apps/android/app/src/main/assets/www/README.md
Normal file
3
apps/android/app/src/main/assets/www/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# WebView for WebRTC calls in SimpleX Chat
|
||||
|
||||
Do NOT edit call.js here, it is compiled abd copied here from call.ts in packages/simplex-chat-webrtc
|
||||
26
apps/android/app/src/main/assets/www/call.html
Normal file
26
apps/android/app/src/main/assets/www/call.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link href="./style.css" rel="stylesheet" />
|
||||
<script src="./lz-string.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
<video
|
||||
id="local-video-stream"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
</body>
|
||||
<footer>
|
||||
<script src="./call.js"></script>
|
||||
</footer>
|
||||
</html>
|
||||
650
apps/android/app/src/main/assets/www/call.js
Normal file
650
apps/android/app/src/main/assets/www/call.js
Normal file
@@ -0,0 +1,650 @@
|
||||
"use strict";
|
||||
// Inspired by
|
||||
// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption
|
||||
var CallMediaType;
|
||||
(function (CallMediaType) {
|
||||
CallMediaType["Audio"] = "audio";
|
||||
CallMediaType["Video"] = "video";
|
||||
})(CallMediaType || (CallMediaType = {}));
|
||||
var VideoCamera;
|
||||
(function (VideoCamera) {
|
||||
VideoCamera["User"] = "user";
|
||||
VideoCamera["Environment"] = "environment";
|
||||
})(VideoCamera || (VideoCamera = {}));
|
||||
// for debugging
|
||||
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
|
||||
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
|
||||
// Global object with cryptrographic/encoding functions
|
||||
const callCrypto = callCryptoFunction();
|
||||
var TransformOperation;
|
||||
(function (TransformOperation) {
|
||||
TransformOperation["Encrypt"] = "encrypt";
|
||||
TransformOperation["Decrypt"] = "decrypt";
|
||||
})(TransformOperation || (TransformOperation = {}));
|
||||
let activeCall;
|
||||
const processCommand = (function () {
|
||||
const defaultIceServers = [
|
||||
{ urls: ["stun:stun.simplex.chat:5349"] },
|
||||
{ urls: ["turn:turn.simplex.chat:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
];
|
||||
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
|
||||
return {
|
||||
peerConnectionConfig: {
|
||||
iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers,
|
||||
iceCandidatePoolSize: 10,
|
||||
encodedInsertableStreams,
|
||||
iceTransportPolicy: relay ? "relay" : "all",
|
||||
},
|
||||
iceCandidates: {
|
||||
delay: 3000,
|
||||
extrasInterval: 2000,
|
||||
extrasTimeout: 8000,
|
||||
},
|
||||
};
|
||||
}
|
||||
function getIceCandidates(conn, config) {
|
||||
return new Promise((resolve, _) => {
|
||||
let candidates = [];
|
||||
let resolved = false;
|
||||
let extrasInterval;
|
||||
let extrasTimeout;
|
||||
const delay = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolveIceCandidates();
|
||||
extrasInterval = setInterval(() => {
|
||||
sendIceCandidates();
|
||||
}, config.iceCandidates.extrasInterval);
|
||||
extrasTimeout = setTimeout(() => {
|
||||
clearInterval(extrasInterval);
|
||||
sendIceCandidates();
|
||||
}, config.iceCandidates.extrasTimeout);
|
||||
}
|
||||
}, config.iceCandidates.delay);
|
||||
conn.onicecandidate = ({ candidate: c }) => c && candidates.push(c);
|
||||
conn.onicegatheringstatechange = () => {
|
||||
if (conn.iceGatheringState == "complete") {
|
||||
if (resolved) {
|
||||
if (extrasInterval)
|
||||
clearInterval(extrasInterval);
|
||||
if (extrasTimeout)
|
||||
clearTimeout(extrasTimeout);
|
||||
sendIceCandidates();
|
||||
}
|
||||
else {
|
||||
resolveIceCandidates();
|
||||
}
|
||||
}
|
||||
};
|
||||
function resolveIceCandidates() {
|
||||
if (delay)
|
||||
clearTimeout(delay);
|
||||
resolved = true;
|
||||
const iceCandidates = serialize(candidates);
|
||||
candidates = [];
|
||||
resolve(iceCandidates);
|
||||
}
|
||||
function sendIceCandidates() {
|
||||
if (candidates.length === 0)
|
||||
return;
|
||||
const iceCandidates = serialize(candidates);
|
||||
candidates = [];
|
||||
sendMessageToNative({ resp: { type: "ice", iceCandidates } });
|
||||
}
|
||||
});
|
||||
}
|
||||
async function initializeCall(config, mediaType, aesKey, useWorker) {
|
||||
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 };
|
||||
await setupMediaStreams(call);
|
||||
pc.addEventListener("connectionstatechange", connectionStateChange);
|
||||
return call;
|
||||
async function connectionStateChange() {
|
||||
sendMessageToNative({
|
||||
resp: {
|
||||
type: "connection",
|
||||
state: {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
iceGatheringState: pc.iceGatheringState,
|
||||
signalingState: pc.signalingState,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
|
||||
pc.removeEventListener("connectionstatechange", connectionStateChange);
|
||||
if (activeCall) {
|
||||
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
|
||||
}
|
||||
endCall();
|
||||
}
|
||||
else if (pc.connectionState == "connected") {
|
||||
const stats = (await pc.getStats());
|
||||
for (const stat of stats.values()) {
|
||||
const { type, state } = stat;
|
||||
if (type === "candidate-pair" && state === "succeeded") {
|
||||
const iceCandidatePair = stat;
|
||||
const resp = {
|
||||
type: "connected",
|
||||
connectionInfo: {
|
||||
iceCandidatePair,
|
||||
localCandidate: stats.get(iceCandidatePair.localCandidateId),
|
||||
remoteCandidate: stats.get(iceCandidatePair.remoteCandidateId),
|
||||
},
|
||||
};
|
||||
setTimeout(() => sendMessageToNative({ resp }), 500);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function serialize(x) {
|
||||
return LZString.compressToBase64(JSON.stringify(x));
|
||||
}
|
||||
function parse(s) {
|
||||
return JSON.parse(LZString.decompressFromBase64(s));
|
||||
}
|
||||
async function processCommand(body) {
|
||||
const { corrId, command } = body;
|
||||
const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection;
|
||||
let resp;
|
||||
try {
|
||||
switch (command.type) {
|
||||
case "capabilities":
|
||||
console.log("starting outgoing call - capabilities");
|
||||
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);
|
||||
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 encryption = supportsInsertableStreams(useWorker);
|
||||
const aesKey = encryption ? command.aesKey : undefined;
|
||||
activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker);
|
||||
const pc = activeCall.connection;
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
// for debugging, returning the command for callee to use
|
||||
// resp = {
|
||||
// type: "offer",
|
||||
// offer: serialize(offer),
|
||||
// iceCandidates: await activeCall.iceCandidates,
|
||||
// capabilities: {encryption},
|
||||
// media,
|
||||
// iceServers,
|
||||
// relay,
|
||||
// aesKey,
|
||||
// useWorker,
|
||||
// }
|
||||
resp = {
|
||||
type: "offer",
|
||||
offer: serialize(offer),
|
||||
iceCandidates: await activeCall.iceCandidates,
|
||||
capabilities: { encryption },
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "offer":
|
||||
if (activeCall) {
|
||||
resp = { type: "error", message: "accept: call already started" };
|
||||
}
|
||||
else if (!supportsInsertableStreams(command.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 pc = activeCall.connection;
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
// same as command for caller to use
|
||||
resp = {
|
||||
type: "answer",
|
||||
answer: serialize(answer),
|
||||
iceCandidates: await activeCall.iceCandidates,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case "answer":
|
||||
if (!pc) {
|
||||
resp = { type: "error", message: "answer: call not started" };
|
||||
}
|
||||
else if (!pc.localDescription) {
|
||||
resp = { type: "error", message: "answer: local description is not set" };
|
||||
}
|
||||
else if (pc.currentRemoteDescription) {
|
||||
resp = { type: "error", message: "answer: remote description already set" };
|
||||
}
|
||||
else {
|
||||
const answer = parse(command.answer);
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
break;
|
||||
case "ice":
|
||||
if (pc) {
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
else {
|
||||
resp = { type: "error", message: "ice: call not started" };
|
||||
}
|
||||
break;
|
||||
case "media":
|
||||
if (!activeCall) {
|
||||
resp = { type: "error", message: "media: call not started" };
|
||||
}
|
||||
else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) {
|
||||
resp = { type: "error", message: "media: no video" };
|
||||
}
|
||||
else {
|
||||
enableMedia(activeCall.localStream, command.media, command.enable);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
break;
|
||||
case "camera":
|
||||
if (!activeCall || !pc) {
|
||||
resp = { type: "error", message: "camera: call not started" };
|
||||
}
|
||||
else {
|
||||
await replaceMedia(activeCall, command.camera);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
break;
|
||||
case "end":
|
||||
endCall();
|
||||
resp = { type: "ok" };
|
||||
break;
|
||||
default:
|
||||
resp = { type: "error", message: "unknown command" };
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
resp = { type: "error", message: `${command.type}: ${e.message}` };
|
||||
}
|
||||
const apiResp = { corrId, resp, command };
|
||||
sendMessageToNative(apiResp);
|
||||
return apiResp;
|
||||
}
|
||||
function endCall() {
|
||||
var _a;
|
||||
try {
|
||||
(_a = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection) === null || _a === void 0 ? void 0 : _a.close();
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
activeCall = undefined;
|
||||
resetVideoElements();
|
||||
}
|
||||
function addIceCandidates(conn, iceCandidates) {
|
||||
for (const c of iceCandidates) {
|
||||
conn.addIceCandidate(new RTCIceCandidate(c));
|
||||
}
|
||||
}
|
||||
async function setupMediaStreams(call) {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
throw Error("no video elements");
|
||||
await setupEncryptionWorker(call);
|
||||
setupLocalStream(call);
|
||||
setupRemoteStream(call);
|
||||
setupCodecPreferences(call);
|
||||
// setupVideoElement(videos.local)
|
||||
// setupVideoElement(videos.remote)
|
||||
videos.local.srcObject = call.localStream;
|
||||
videos.remote.srcObject = call.remoteStream;
|
||||
}
|
||||
async function setupEncryptionWorker(call) {
|
||||
if (call.aesKey) {
|
||||
if (!call.key)
|
||||
call.key = await callCrypto.decodeAesKey(call.aesKey);
|
||||
if (call.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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
function setupLocalStream(call) {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
throw Error("no video elements");
|
||||
const pc = call.connection;
|
||||
let { localStream } = call;
|
||||
for (const track of localStream.getTracks()) {
|
||||
pc.addTrack(track, localStream);
|
||||
}
|
||||
if (call.aesKey && call.key) {
|
||||
console.log("set up encryption for sending");
|
||||
for (const sender of pc.getSenders()) {
|
||||
setupPeerTransform(TransformOperation.Encrypt, sender, call.worker, call.aesKey, call.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
function setupRemoteStream(call) {
|
||||
// Pull tracks from remote stream as they arrive add them to remoteStream video
|
||||
const pc = call.connection;
|
||||
pc.ontrack = (event) => {
|
||||
try {
|
||||
if (call.aesKey && call.key) {
|
||||
console.log("set up decryption for receiving");
|
||||
setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key);
|
||||
}
|
||||
for (const stream of event.streams) {
|
||||
for (const track of stream.getTracks()) {
|
||||
call.remoteStream.addTrack(track);
|
||||
}
|
||||
}
|
||||
console.log(`ontrack success`);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`ontrack error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
function setupCodecPreferences(call) {
|
||||
// We assume VP8 encoding in the decode/encode stages to get the initial
|
||||
// bytes to pass as plaintext so we enforce that here.
|
||||
// VP8 is supported by all supports of webrtc.
|
||||
// Use of VP8 by default may also reduce depacketisation issues.
|
||||
// We do not encrypt the first couple of bytes of the payload so that the
|
||||
// video elements can work by determining video keyframes and the opus mode
|
||||
// being used. This appears to be necessary for any video feed at all.
|
||||
// For VP8 this is the content described in
|
||||
// https://tools.ietf.org/html/rfc6386#section-9.1
|
||||
// which is 10 bytes for key frames and 3 bytes for delta frames.
|
||||
// For opus (where encodedFrame.type is not set) this is the TOC byte from
|
||||
// https://tools.ietf.org/html/rfc6716#section-3.1
|
||||
var _a;
|
||||
const capabilities = RTCRtpSender.getCapabilities("video");
|
||||
if (capabilities) {
|
||||
const { codecs } = capabilities;
|
||||
const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8");
|
||||
const selectedCodec = codecs[selectedCodecIndex];
|
||||
codecs.splice(selectedCodecIndex, 1);
|
||||
codecs.unshift(selectedCodec);
|
||||
for (const t of call.connection.getTransceivers()) {
|
||||
if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") {
|
||||
t.setCodecPreferences(codecs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async function replaceMedia(call, camera) {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
throw Error("no video elements");
|
||||
const pc = call.connection;
|
||||
for (const t of call.localStream.getTracks())
|
||||
t.stop();
|
||||
call.localCamera = camera;
|
||||
const localStream = await getLocalMediaStream(call.localMedia, camera);
|
||||
replaceTracks(pc, localStream.getVideoTracks());
|
||||
replaceTracks(pc, localStream.getAudioTracks());
|
||||
call.localStream = localStream;
|
||||
videos.local.srcObject = localStream;
|
||||
}
|
||||
function replaceTracks(pc, tracks) {
|
||||
if (!tracks.length)
|
||||
return;
|
||||
const sender = pc.getSenders().find((s) => { var _a; return ((_a = s.track) === null || _a === void 0 ? void 0 : _a.kind) === tracks[0].kind; });
|
||||
if (sender)
|
||||
for (const t of tracks)
|
||||
sender.replaceTrack(t);
|
||||
}
|
||||
function setupPeerTransform(operation, peer, worker, aesKey, key) {
|
||||
if (worker && "RTCRtpScriptTransform" in window) {
|
||||
console.log(`${operation} with worker & RTCRtpScriptTransform`);
|
||||
peer.transform = new RTCRtpScriptTransform(worker, { operation, aesKey });
|
||||
}
|
||||
else if ("createEncodedStreams" in peer) {
|
||||
const { readable, writable } = peer.createEncodedStreams();
|
||||
if (worker) {
|
||||
console.log(`${operation} with worker`);
|
||||
worker.postMessage({ operation, readable, writable, aesKey }, [readable, writable]);
|
||||
}
|
||||
else {
|
||||
console.log(`${operation} without worker`);
|
||||
const transform = callCrypto.transformFrame[operation](key);
|
||||
readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log(`no ${operation}`);
|
||||
}
|
||||
}
|
||||
function getLocalMediaStream(mediaType, facingMode) {
|
||||
const constraints = callMediaConstraints(mediaType, facingMode);
|
||||
return navigator.mediaDevices.getUserMedia(constraints);
|
||||
}
|
||||
function callMediaConstraints(mediaType, facingMode) {
|
||||
switch (mediaType) {
|
||||
case CallMediaType.Audio:
|
||||
return { audio: true, video: false };
|
||||
case CallMediaType.Video:
|
||||
return {
|
||||
audio: true,
|
||||
video: {
|
||||
frameRate: 24,
|
||||
width: {
|
||||
min: 480,
|
||||
ideal: 720,
|
||||
max: 1280,
|
||||
},
|
||||
aspectRatio: 1.33,
|
||||
facingMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
function supportsInsertableStreams(useWorker) {
|
||||
return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) ||
|
||||
(!!useWorker && "RTCRtpScriptTransform" in window));
|
||||
}
|
||||
function resetVideoElements() {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
return;
|
||||
videos.local.srcObject = null;
|
||||
videos.remote.srcObject = null;
|
||||
}
|
||||
function getVideoElements() {
|
||||
const local = document.getElementById("local-video-stream");
|
||||
const remote = document.getElementById("remote-video-stream");
|
||||
if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement))
|
||||
return;
|
||||
return { local, remote };
|
||||
}
|
||||
// function setupVideoElement(video: HTMLElement) {
|
||||
// // TODO use display: none
|
||||
// video.style.opacity = "0"
|
||||
// video.onplaying = () => {
|
||||
// video.style.opacity = "1"
|
||||
// }
|
||||
// }
|
||||
function enableMedia(s, media, enable) {
|
||||
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
|
||||
for (const t of tracks)
|
||||
t.enabled = enable;
|
||||
}
|
||||
return processCommand;
|
||||
})();
|
||||
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
|
||||
function callCryptoFunction() {
|
||||
const initialPlainTextRequired = {
|
||||
key: 10,
|
||||
delta: 3,
|
||||
};
|
||||
const IV_LENGTH = 12;
|
||||
function encryptFrame(key) {
|
||||
return async (frame, controller) => {
|
||||
const data = new Uint8Array(frame.data);
|
||||
const n = initialPlainTextRequired[frame.type] || 1;
|
||||
const iv = randomIV();
|
||||
const initial = data.subarray(0, n);
|
||||
const plaintext = data.subarray(n, data.byteLength);
|
||||
try {
|
||||
const ciphertext = new Uint8Array(plaintext.length ? await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext) : 0);
|
||||
frame.data = concatN(initial, ciphertext, iv).buffer;
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`encryption error ${e}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
function decryptFrame(key) {
|
||||
return async (frame, controller) => {
|
||||
const data = new Uint8Array(frame.data);
|
||||
const n = initialPlainTextRequired[frame.type] || 1;
|
||||
const initial = data.subarray(0, n);
|
||||
const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH);
|
||||
const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength);
|
||||
try {
|
||||
const plaintext = new Uint8Array(ciphertext.length ? await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext) : 0);
|
||||
frame.data = concatN(initial, plaintext).buffer;
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`decryption error ${e}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
function decodeAesKey(aesKey) {
|
||||
const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey));
|
||||
return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
||||
}
|
||||
function concatN(...bs) {
|
||||
const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0));
|
||||
bs.reduce((offset, b) => {
|
||||
a.set(b, offset);
|
||||
return offset + b.byteLength;
|
||||
}, 0);
|
||||
return a;
|
||||
}
|
||||
function randomIV() {
|
||||
return crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
}
|
||||
const base64urlChars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("").map((c) => c.charCodeAt(0)));
|
||||
const base64urlLookup = new Array(256);
|
||||
base64urlChars.forEach((c, i) => (base64urlLookup[c] = i));
|
||||
const char_equal = "=".charCodeAt(0);
|
||||
function encodeAscii(s) {
|
||||
const a = new Uint8Array(s.length);
|
||||
let i = s.length;
|
||||
while (i--)
|
||||
a[i] = s.charCodeAt(i);
|
||||
return a;
|
||||
}
|
||||
function decodeAscii(a) {
|
||||
let s = "";
|
||||
for (let i = 0; i < a.length; i++)
|
||||
s += String.fromCharCode(a[i]);
|
||||
return s;
|
||||
}
|
||||
function encodeBase64url(a) {
|
||||
const len = a.length;
|
||||
const b64len = Math.ceil(len / 3) * 4;
|
||||
const b64 = new Uint8Array(b64len);
|
||||
let j = 0;
|
||||
for (let i = 0; i < len; i += 3) {
|
||||
b64[j++] = base64urlChars[a[i] >> 2];
|
||||
b64[j++] = base64urlChars[((a[i] & 3) << 4) | (a[i + 1] >> 4)];
|
||||
b64[j++] = base64urlChars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)];
|
||||
b64[j++] = base64urlChars[a[i + 2] & 63];
|
||||
}
|
||||
if (len % 3)
|
||||
b64[b64len - 1] = char_equal;
|
||||
if (len % 3 === 1)
|
||||
b64[b64len - 2] = char_equal;
|
||||
return b64;
|
||||
}
|
||||
function decodeBase64url(b64) {
|
||||
let len = b64.length;
|
||||
if (len % 4)
|
||||
return;
|
||||
let bLen = (len * 3) / 4;
|
||||
if (b64[len - 1] === char_equal) {
|
||||
len--;
|
||||
bLen--;
|
||||
if (b64[len - 1] === char_equal) {
|
||||
len--;
|
||||
bLen--;
|
||||
}
|
||||
}
|
||||
const bytes = new Uint8Array(bLen);
|
||||
let i = 0;
|
||||
let pos = 0;
|
||||
while (i < len) {
|
||||
const enc1 = base64urlLookup[b64[i++]];
|
||||
const enc2 = i < len ? base64urlLookup[b64[i++]] : 0;
|
||||
const enc3 = i < len ? base64urlLookup[b64[i++]] : 0;
|
||||
const enc4 = i < len ? base64urlLookup[b64[i++]] : 0;
|
||||
if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined)
|
||||
return;
|
||||
bytes[pos++] = (enc1 << 2) | (enc2 >> 4);
|
||||
bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2);
|
||||
bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
return {
|
||||
transformFrame: { encrypt: encryptFrame, decrypt: decryptFrame },
|
||||
decodeAesKey,
|
||||
encodeAscii,
|
||||
decodeAscii,
|
||||
encodeBase64url,
|
||||
decodeBase64url,
|
||||
};
|
||||
}
|
||||
// If the worker is used for decryption, this function code (as string) is used to load the worker via Blob
|
||||
// We have to use worker optionally, as it crashes in Android web view, regardless of how it is loaded
|
||||
function workerFunction() {
|
||||
// encryption with createEncodedStreams support
|
||||
self.addEventListener("message", async ({ data }) => {
|
||||
await setupTransform(data);
|
||||
});
|
||||
// encryption using RTCRtpScriptTransform.
|
||||
if ("RTCTransformEvent" in self) {
|
||||
self.addEventListener("rtctransform", async ({ transformer }) => {
|
||||
try {
|
||||
const { operation, aesKey } = transformer.options;
|
||||
const { readable, writable } = transformer;
|
||||
await setupTransform({ operation, aesKey, readable, writable });
|
||||
self.postMessage({ result: "setupTransform success" });
|
||||
}
|
||||
catch (e) {
|
||||
self.postMessage({ message: `setupTransform error: ${e.message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
async function setupTransform({ operation, aesKey, readable, writable }) {
|
||||
const key = await callCrypto.decodeAesKey(aesKey);
|
||||
const transform = callCrypto.transformFrame[operation](key);
|
||||
readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=call.js.map
|
||||
1
apps/android/app/src/main/assets/www/lz-string.min.js
vendored
Normal file
1
apps/android/app/src/main/assets/www/lz-string.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;n<o.length;n++)t[o][o.charAt(n)]=n}return t[o][r]}var r=String.fromCharCode,n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",t={},i={compressToBase64:function(o){if(null==o)return"";var r=i._compress(o,6,function(o){return n.charAt(o)});switch(r.length%4){default:case 0:return r;case 1:return r+"===";case 2:return r+"==";case 3:return r+"="}},decompressFromBase64:function(r){return null==r?"":""==r?null:i._decompress(r.length,32,function(e){return o(n,r.charAt(e))})},compressToUTF16:function(o){return null==o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(o){return null==o?"":""==o?null:i._decompress(o.length,16384,function(r){return o.charCodeAt(r)-32})},compressToUint8Array:function(o){for(var r=i.compress(o),n=new Uint8Array(2*r.length),e=0,t=r.length;t>e;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<o.length;i+=1)if(u=o.charAt(i),Object.prototype.hasOwnProperty.call(s,u)||(s[u]=f++,p[u]=!0),c=a+u,Object.prototype.hasOwnProperty.call(s,c))a=c;else{if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString);
|
||||
41
apps/android/app/src/main/assets/www/style.css
Normal file
41
apps/android/app/src/main/assets/www/style.css
Normal file
@@ -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;
|
||||
}
|
||||
68
apps/android/app/src/main/cpp/CMakeLists.txt
Normal file
68
apps/android/app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,68 @@
|
||||
# For more information about using CMake with Android Studio, read the
|
||||
# documentation: https://d.android.com/studio/projects/add-native-code.html
|
||||
|
||||
# Sets the minimum version of CMake required to build the native library.
|
||||
|
||||
cmake_minimum_required(VERSION 3.10.2)
|
||||
|
||||
# Declares and names the project.
|
||||
|
||||
project("app")
|
||||
|
||||
# Creates and names a library, sets it as either STATIC
|
||||
# or SHARED, and provides the relative paths to its source code.
|
||||
# You can define multiple libraries, and CMake builds them for you.
|
||||
# Gradle automatically packages shared libraries with your APK.
|
||||
|
||||
add_library( # Sets the name of the library.
|
||||
app-lib
|
||||
|
||||
# Sets the library as a shared library.
|
||||
SHARED
|
||||
|
||||
# Provides a relative path to your source file(s).
|
||||
simplex-api.c)
|
||||
|
||||
# Searches for a specified prebuilt library and stores the path as a
|
||||
# variable. Because CMake includes system libraries in the search path by
|
||||
# default, you only need to specify the name of the public NDK library
|
||||
# you want to add. CMake verifies that the library exists before
|
||||
# completing its build.
|
||||
|
||||
find_library( # Sets the name of the path variable.
|
||||
log-lib
|
||||
|
||||
# Specifies the name of the NDK library that
|
||||
# you want CMake to locate.
|
||||
log)
|
||||
|
||||
find_library( # Sets the name of the path variable.
|
||||
c-lib
|
||||
|
||||
# Specifies the name of the NDK library that
|
||||
# you want CMake to locate.
|
||||
c
|
||||
NAMES libc.so
|
||||
REQUIRED)
|
||||
|
||||
add_library( simplex SHARED IMPORTED )
|
||||
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsimplex.so)
|
||||
|
||||
add_library( support SHARED IMPORTED )
|
||||
set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
|
||||
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link multiple libraries, such as libraries you define in this
|
||||
# build script, prebuilt third-party libraries, or system libraries.
|
||||
|
||||
target_link_libraries( # Specifies the target library.
|
||||
app-lib
|
||||
|
||||
simplex support
|
||||
|
||||
# Links the target library to the log library
|
||||
# included in the NDK.
|
||||
${log-lib})
|
||||
50
apps/android/app/src/main/cpp/simplex-api.c
Normal file
50
apps/android/app/src/main/cpp/simplex-api.c
Normal file
@@ -0,0 +1,50 @@
|
||||
#include <jni.h>
|
||||
|
||||
// from the RTS
|
||||
void hs_init(int * argc, char **argv[]);
|
||||
|
||||
// from android-support
|
||||
void setLineBuffering(void);
|
||||
int pipe_std_to_socket(const char * name);
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
||||
int ret = pipe_std_to_socket(name);
|
||||
(*env)->ReleaseStringUTFChars(env, socket_name, name);
|
||||
return ret;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
|
||||
hs_init(NULL, NULL);
|
||||
setLineBuffering();
|
||||
}
|
||||
|
||||
// from simplex-chat
|
||||
typedef void* chat_ctrl;
|
||||
|
||||
extern chat_ctrl chat_init(const char * path);
|
||||
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||
extern char *chat_recv_msg(chat_ctrl ctrl);
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
|
||||
const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE);
|
||||
jlong res = (jlong)chat_init(_data);
|
||||
(*env)->ReleaseStringUTFChars(env, datadir, _data);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
|
||||
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
|
||||
(*env)->ReleaseStringUTFChars(env, msg, _msg);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
|
||||
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
|
||||
}
|
||||
BIN
apps/android/app/src/main/icon-playstore.png
Normal file
BIN
apps/android/app/src/main/icon-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
400
apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
Normal file
400
apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
Normal file
@@ -0,0 +1,400 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock.elapsedRealtime
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Replay
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.SplashView
|
||||
import chat.simplex.app.views.call.ActiveCallView
|
||||
import chat.simplex.app.views.call.IncomingCallAlertView
|
||||
import chat.simplex.app.views.chat.ChatView
|
||||
import chat.simplex.app.views.chatlist.ChatListView
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.connectViaUri
|
||||
import chat.simplex.app.views.newchat.withUriAction
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
private val vm by viewModels<SimplexViewModel>()
|
||||
private val chatController by lazy { (application as SimplexApp).chatController }
|
||||
private val userAuthorized = mutableStateOf<Boolean?>(null)
|
||||
private val enteredBackground = mutableStateOf<Long?>(null)
|
||||
private val laFailed = mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
// testJson()
|
||||
val m = vm.chatModel
|
||||
processNotificationIntent(intent, m)
|
||||
setContent {
|
||||
SimpleXTheme {
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
MainPage(
|
||||
m,
|
||||
userAuthorized,
|
||||
laFailed,
|
||||
::runAuthenticate,
|
||||
::setPerformLA,
|
||||
showLANotice = { m.controller.showLANotice(this) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
schedulePeriodicServiceRestartWorker()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
processIntent(intent, vm.chatModel)
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
Lifecycle.Event.ON_START -> {
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runAuthenticate() {
|
||||
val m = vm.chatModel
|
||||
if (!m.controller.appPrefs.performLA.get()) {
|
||||
userAuthorized.value = true
|
||||
} else {
|
||||
userAuthorized.value = false
|
||||
ModalManager.shared.closeModals()
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_unlock),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
userAuthorized.value = true
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
laFailed.value = true
|
||||
laErrorToast(applicationContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
laFailed.value = true
|
||||
laFailedToast(applicationContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun schedulePeriodicServiceRestartWorker() {
|
||||
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.addTag(SimplexService.TAG)
|
||||
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||
WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
private fun setPerformLA(on: Boolean) {
|
||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
enableLA()
|
||||
} else {
|
||||
disableLA()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableLA() {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_enable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laErrorToast(applicationContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laFailedToast(applicationContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableLA() {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_disable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
}
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laErrorToast(applicationContext, laResult.errString)
|
||||
}
|
||||
LAResult.Failed -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laFailedToast(applicationContext)
|
||||
}
|
||||
LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class SimplexViewModel(application: Application): AndroidViewModel(application) {
|
||||
val app = getApplication<SimplexApp>()
|
||||
val chatModel = app.chatModel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainPage(
|
||||
chatModel: ChatModel,
|
||||
userAuthorized: MutableState<Boolean?>,
|
||||
laFailed: MutableState<Boolean>,
|
||||
runAuthenticate: () -> Unit,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showLANotice: () -> Unit
|
||||
) {
|
||||
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
|
||||
var chatsAccessAuthorized by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(userAuthorized.value) {
|
||||
if (chatModel.controller.appPrefs.performLA.get()) {
|
||||
delay(500L)
|
||||
}
|
||||
chatsAccessAuthorized = userAuthorized.value == true
|
||||
}
|
||||
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(showAdvertiseLAAlert) {
|
||||
if (
|
||||
!chatModel.controller.appPrefs.laNoticeShown.get()
|
||||
&& showAdvertiseLAAlert
|
||||
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
|
||||
&& chatModel.chats.isNotEmpty()
|
||||
&& chatModel.activeCallInvitation.value == null
|
||||
) {
|
||||
showLANotice()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
if (chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value) {
|
||||
ModalManager.shared.closeModals()
|
||||
chatModel.clearOverlays.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun retryAuthView() {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.auth_retry),
|
||||
icon = Icons.Outlined.Replay,
|
||||
click = {
|
||||
laFailed.value = false
|
||||
runAuthenticate()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
val onboarding = chatModel.onboardingStage.value
|
||||
val userCreated = chatModel.userCreated.value
|
||||
when {
|
||||
onboarding == null || userCreated == null -> SplashView()
|
||||
!chatsAccessAuthorized -> {
|
||||
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||
retryAuthView()
|
||||
} else {
|
||||
SplashView()
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
|
||||
Box {
|
||||
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
||||
else {
|
||||
showAdvertiseLAAlert = true
|
||||
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA = { setPerformLA(it) })
|
||||
else ChatView(chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo ->
|
||||
Box(Modifier.padding(horizontal = 20.dp)) {
|
||||
SimpleXInfo(chatModel, onboarding = true)
|
||||
}
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
val invitation = chatModel.activeCallInvitation.value
|
||||
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||
AlertManager.shared.showInView()
|
||||
}
|
||||
}
|
||||
|
||||
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
when (intent?.action) {
|
||||
NtfManager.OpenChatAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||
if (chatId != null) {
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
chatModel.clearOverlays.value = true
|
||||
if (cInfo != null) withApi { openChat(cInfo, chatModel) }
|
||||
}
|
||||
}
|
||||
NtfManager.ShowChatsAction -> {
|
||||
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
}
|
||||
NtfManager.AcceptCallAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
if (chatId == null || chatId == "") return
|
||||
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
|
||||
chatModel.clearOverlays.value = true
|
||||
val invitation = chatModel.callInvitations[chatId]
|
||||
if (invitation == null) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended))
|
||||
} else {
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
when (intent?.action) {
|
||||
"android.intent.action.VIEW" -> {
|
||||
val uri = intent.data
|
||||
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
// TODO open from chat list view
|
||||
chatModel.appOpenUrl.value = uri
|
||||
} else {
|
||||
withUriAction(uri) { action ->
|
||||
val title = when (action) {
|
||||
"contact" -> generalGetString(R.string.connect_via_contact_link)
|
||||
"invitation" -> generalGetString(R.string.connect_via_invitation_link)
|
||||
else -> {
|
||||
Log.e(TAG, "URI has unexpected action. Alert shown.")
|
||||
action
|
||||
}
|
||||
}
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = title,
|
||||
text = generalGetString(R.string.profile_will_be_sent_to_contact_sending_link),
|
||||
confirmText = generalGetString(R.string.connect_via_link_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: connecting")
|
||||
connectViaUri(chatModel, action, uri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
//fun testJson() {
|
||||
// val str: String = """
|
||||
// """.trimIndent()
|
||||
//
|
||||
// println(json.decodeFromString<APIResponse>(str))
|
||||
//}
|
||||
122
apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
Normal file
122
apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
Normal file
@@ -0,0 +1,122 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.Application
|
||||
import android.net.LocalServerSocket
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.getFilesDirectory
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.*
|
||||
import java.util.concurrent.Semaphore
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
const val TAG = "SIMPLEX"
|
||||
|
||||
// ghc's rts
|
||||
external fun initHS()
|
||||
// android-support
|
||||
external fun pipeStdOutToSocket(socketName: String) : Int
|
||||
|
||||
// SimpleX API
|
||||
typealias ChatCtrl = Long
|
||||
external fun chatInit(path: String): ChatCtrl
|
||||
external fun chatSendCmd(ctrl: ChatCtrl, msg: String) : String
|
||||
external fun chatRecvMsg(ctrl: ChatCtrl) : String
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
val chatController: ChatController by lazy {
|
||||
val ctrl = chatInit(getFilesDirectory(applicationContext))
|
||||
ChatController(ctrl, ntfManager, applicationContext, appPreferences)
|
||||
}
|
||||
|
||||
val chatModel: ChatModel by lazy {
|
||||
chatController.chatModel
|
||||
}
|
||||
|
||||
private val ntfManager: NtfManager by lazy {
|
||||
NtfManager(applicationContext, appPreferences)
|
||||
}
|
||||
|
||||
private val appPreferences: AppPreferences by lazy {
|
||||
AppPreferences(applicationContext)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
context = this
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
withApi {
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user == null) {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
} else {
|
||||
chatController.startChat(user)
|
||||
SimplexService.start(applicationContext)
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
Log.d(TAG, "onStateChanged: $event")
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP ->
|
||||
if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext)
|
||||
Lifecycle.Event.ON_START ->
|
||||
SimplexService.start(applicationContext)
|
||||
Lifecycle.Event.ON_RESUME ->
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var context: SimplexApp private set
|
||||
|
||||
init {
|
||||
val socketName = "local.socket.address.listen.native.cmd2"
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
val server = LocalServerSocket(socketName)
|
||||
Log.d(TAG, "started server")
|
||||
s.release()
|
||||
val receiver = server.accept()
|
||||
Log.d(TAG, "started receiver")
|
||||
val logbuffer = FifoQueue<String>(500)
|
||||
if (receiver != null) {
|
||||
val inStream = receiver.inputStream
|
||||
val inStreamReader = InputStreamReader(inStream)
|
||||
val input = BufferedReader(inStreamReader)
|
||||
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
Log.w("$TAG (stdout/stderr)", line)
|
||||
logbuffer.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
System.loadLibrary("app-lib")
|
||||
|
||||
s.acquire()
|
||||
pipeStdOutToSocket(socketName)
|
||||
|
||||
initHS()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
|
||||
override fun add(element: E): Boolean {
|
||||
if(size > capacity) removeFirst()
|
||||
return super.add(element)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// based on:
|
||||
// https://robertohuertas.com/2019/06/29/android_foreground_services/
|
||||
// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
|
||||
|
||||
class SimplexService: Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var isServiceStarted = false
|
||||
private var isStartingService = false
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
private val chatController by lazy { (application as SimplexApp).chatController }
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand startId: $startId")
|
||||
if (intent != null) {
|
||||
val action = intent.action
|
||||
Log.d(TAG, "intent action $action")
|
||||
when (action) {
|
||||
Action.START.name -> startService()
|
||||
Action.STOP.name -> stopService()
|
||||
else -> Log.e(TAG, "No action in the intent")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "null intent. Probably restarted by the system.")
|
||||
}
|
||||
return START_STICKY // to restart if killed
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Simplex service created")
|
||||
val title = getString(R.string.simplex_service_notification_title)
|
||||
val text = getString(R.string.simplex_service_notification_text)
|
||||
notificationManager = createNotificationChannel()
|
||||
serviceNotification = createNotification(title, text)
|
||||
|
||||
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Simplex service destroyed")
|
||||
stopService()
|
||||
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart if necessary!
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
Log.d(TAG, "SimplexService startService")
|
||||
if (isServiceStarted || isStartingService) return
|
||||
val self = this
|
||||
isStartingService = true
|
||||
withApi {
|
||||
try {
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user == null) {
|
||||
chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
} else {
|
||||
Log.w(TAG, "Starting foreground service")
|
||||
chatController.startChat(user)
|
||||
chatController.startReceiver()
|
||||
isServiceStarted = true
|
||||
saveServiceState(self, ServiceState.STARTED)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isStartingService = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
Log.d(TAG, "Stopping foreground service")
|
||||
try {
|
||||
wakeLock?.let {
|
||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||
}
|
||||
wakeLock = null
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
||||
}
|
||||
|
||||
isServiceStarted = false
|
||||
saveServiceState(this, ServiceState.STOPPED)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(): NotificationManager? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).let {
|
||||
it.setShowBadge(false) // no long-press badge
|
||||
it
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
return notificationManager
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun createNotification(title: String, text: String): Notification {
|
||||
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
|
||||
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setSound(null)
|
||||
.setShowWhen(false) // no date/time
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null // no binding
|
||||
}
|
||||
|
||||
// re-schedules the task when "Clear recent apps" is pressed
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
|
||||
it.setPackage(packageName)
|
||||
};
|
||||
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
|
||||
applicationContext.getSystemService(Context.ALARM_SERVICE);
|
||||
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
|
||||
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
|
||||
}
|
||||
|
||||
// restart on reboot
|
||||
class StartReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "StartReceiver: onReceive called")
|
||||
scheduleStart(context)
|
||||
}
|
||||
}
|
||||
|
||||
// restart on destruction
|
||||
class AutoRestartReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "AutoRestartReceiver: onReceive called")
|
||||
scheduleStart(context)
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val id = this.id
|
||||
if (context.applicationContext !is Application) {
|
||||
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: $id)")
|
||||
return Result.failure()
|
||||
}
|
||||
if (getServiceState(context) == ServiceState.STARTED) {
|
||||
Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: $id)")
|
||||
start(context)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
enum class Action {
|
||||
START,
|
||||
STOP
|
||||
}
|
||||
|
||||
enum class ServiceState {
|
||||
STARTED,
|
||||
STOPPED,
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "SIMPLEX_SERVICE"
|
||||
const val NOTIFICATION_CHANNEL_ID = "chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION"
|
||||
const val NOTIFICATION_CHANNEL_NAME = "SimpleX Chat service"
|
||||
const val SIMPLEX_SERVICE_ID = 6789
|
||||
const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
|
||||
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
|
||||
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
|
||||
|
||||
private const val WAKE_LOCK_TAG = "SimplexService::lock"
|
||||
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
|
||||
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
||||
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||
|
||||
fun scheduleStart(context: Context) {
|
||||
Log.d(TAG, "Enqueuing work to start subscriber service")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
|
||||
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
|
||||
}
|
||||
|
||||
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
||||
|
||||
suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
|
||||
|
||||
private suspend fun serviceAction(context: Context, action: Action) {
|
||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||
withContext(Dispatchers.IO) {
|
||||
Intent(context, SimplexService::class.java).also {
|
||||
it.action = action.name
|
||||
ContextCompat.startForegroundService(context, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restart(context: Context) {
|
||||
Intent(context, SimplexService::class.java).also { intent ->
|
||||
context.stopService(intent) // Service will auto-restart
|
||||
}
|
||||
}
|
||||
|
||||
fun saveServiceState(context: Context, state: ServiceState) {
|
||||
getPreferences(context).edit()
|
||||
.putString(SHARED_PREFS_SERVICE_STATE, state.name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getServiceState(context: Context): ServiceState {
|
||||
val value = getPreferences(context)
|
||||
.getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
|
||||
return ServiceState.valueOf(value!!)
|
||||
}
|
||||
|
||||
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
1137
apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
Normal file
1137
apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,180 @@
|
||||
package chat.simplex.app.model
|
||||
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.AudioAttributes
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class NtfManager(val context: Context, private val appPreferences: AppPreferences) {
|
||||
companion object {
|
||||
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
|
||||
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
||||
|
||||
// DO NOT change notification channel settings / names
|
||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
|
||||
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
|
||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||
const val CallNotificationId: Int = -1
|
||||
}
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private var prevNtfTime = mutableMapOf<String, Long>()
|
||||
private val msgNtfTimeoutMs = 30000L
|
||||
|
||||
init {
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, "SimpleX Chat messages", NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, "SimpleX Chat calls (lock screen)", NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel())
|
||||
}
|
||||
|
||||
private fun callNotificationChannel(): NotificationChannel {
|
||||
val callChannel = NotificationChannel(CallChannel, "SimpleX Chat calls", NotificationManager.IMPORTANCE_HIGH)
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.build()
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
||||
callChannel.setSound(soundUri, attrs)
|
||||
callChannel.enableVibration(true)
|
||||
return callChannel
|
||||
}
|
||||
|
||||
fun cancelNotificationsForChat(chatId: String) {
|
||||
prevNtfTime.remove(chatId)
|
||||
manager.cancel(chatId.hashCode())
|
||||
val msgNtfs = manager.activeNotifications.filter {
|
||||
ntf -> ntf.notification.channelId == MessageChannel
|
||||
}
|
||||
if (msgNtfs.count() == 1) {
|
||||
// Have a group notification with no children so cancel it
|
||||
manager.cancel(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(chatId: String, displayName: String, msgText: String) {
|
||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||
prevNtfTime[chatId] = now
|
||||
|
||||
val notification = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setContentTitle(displayName)
|
||||
.setContentText(msgText)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
|
||||
.setSilent(recentNotification)
|
||||
.build()
|
||||
|
||||
val summary = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setGroup(MessageGroup)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setGroupSummary(true)
|
||||
.setContentIntent(chatPendingIntent(ShowChatsAction))
|
||||
.build()
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
||||
notify(chatId.hashCode(), notification)
|
||||
notify(0, summary)
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyCallInvitation(invitation: CallInvitation) {
|
||||
if (isAppOnForeground(context)) return
|
||||
val contactId = invitation.contact.id
|
||||
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||
val keyguardManager = getKeyguardManager(context)
|
||||
val image = invitation.contact.image
|
||||
var ntfBuilder =
|
||||
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, LockScreenCallChannel)
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setSilent(true)
|
||||
} else {
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
||||
.setSound(soundUri)
|
||||
}
|
||||
val text = generalGetString(
|
||||
if (invitation.peerMedia == CallMediaType.Video) {
|
||||
if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||
} else {
|
||||
if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||
}
|
||||
)
|
||||
ntfBuilder = ntfBuilder
|
||||
.setContentTitle(invitation.contact.displayName)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setSmallIcon(R.drawable.ntf_icon)
|
||||
.setLargeIcon(if (image == null) BitmapFactory.decodeResource(context.resources, R.drawable.icon) else base64ToBitmap(image))
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
notify(CallNotificationId, ntfBuilder.build())
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelCallNotification() {
|
||||
manager.cancel(CallNotificationId)
|
||||
}
|
||||
|
||||
private fun hideSecrets(cItem: ChatItem) : String {
|
||||
val md = cItem.formattedText
|
||||
return if (md == null) {
|
||||
if (cItem.content.text != "") {
|
||||
cItem.content.text
|
||||
} else {
|
||||
cItem.file?.fileName ?: ""
|
||||
}
|
||||
} else {
|
||||
var res = ""
|
||||
for (ft in md) {
|
||||
res += if (ft.format is Format.Secret) "..." else ft.text
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
|
||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||
var intent = Intent(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.setAction(intentAction)
|
||||
if (chatId != null) intent = intent.putExtra("chatId", chatId)
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
1360
apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
Normal file
1360
apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple200 = Color(0xFFBB86FC)
|
||||
val Purple500 = Color(0xFF6200EE)
|
||||
val Purple700 = Color(0xFF3700B3)
|
||||
val Teal200 = Color(0xFF03DAC5)
|
||||
val Gray = Color(0x22222222)
|
||||
val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files
|
||||
val SimplexGreen = Color(77, 218, 103, 255)
|
||||
val SecretColor = Color(0x40808080)
|
||||
val LightGray = Color(241, 242, 246, 255)
|
||||
val DarkGray = Color(43, 44, 46, 255)
|
||||
val HighOrLowlight = Color(139, 135, 134, 255)
|
||||
val MessagePreviewDark = Color(179, 175, 174, 255)
|
||||
val MessagePreviewLight = Color(49, 45, 44, 255)
|
||||
val ToolbarLight = Color(220, 220, 220, 20)
|
||||
val ToolbarDark = Color(80, 80, 80, 20)
|
||||
val SettingsBackgroundLight = Color(220, 216, 215, 90)
|
||||
val GroupDark = Color(80, 80, 80, 60)
|
||||
val IncomingCallLight = Color(239, 237, 236, 255)
|
||||
val IncomingCallDark = Color(34, 30, 29, 255)
|
||||
val WarningOrange = Color(255, 127, 0, 255)
|
||||
val FileLight = Color(183, 190, 199, 255)
|
||||
val FileDark = Color(101, 101, 106, 255)
|
||||
@@ -0,0 +1,11 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Shapes = Shapes(
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(4.dp),
|
||||
large = RoundedCornerShape(0.dp)
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = DarkGray,
|
||||
// background = Color.Black,
|
||||
// surface = Color.Black,
|
||||
// background = Color(0xFF121212),
|
||||
// surface = Color(0xFF121212),
|
||||
// error = Color(0xFFCF6679),
|
||||
onBackground = Color(0xFFFFFBFA),
|
||||
onSurface = Color(0xFFFFFBFA),
|
||||
// onError: Color = Color.Black,
|
||||
)
|
||||
private val LightColorPalette = lightColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = LightGray,
|
||||
// background = Color.White,
|
||||
// surface = Color.White
|
||||
// onPrimary = Color.White,
|
||||
// onSecondary = Color.Black,
|
||||
// onBackground = Color.Black,
|
||||
// onSurface = Color.Black,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SimpleXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
||||
val colors = if (darkTheme) {
|
||||
DarkColorPalette
|
||||
} else {
|
||||
LightColorPalette
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colors = colors,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.material.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
|
||||
// https://github.com/rsms/inter
|
||||
val Inter = FontFamily(
|
||||
Font(R.font.inter_regular),
|
||||
Font(R.font.inter_italic, style = FontStyle.Italic),
|
||||
Font(R.font.inter_bold, weight = FontWeight.Bold),
|
||||
Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
|
||||
Font(R.font.inter_medium, weight = FontWeight.Medium),
|
||||
)
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
h1 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp,
|
||||
),
|
||||
h2 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp
|
||||
),
|
||||
h3 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 18.5.sp
|
||||
),
|
||||
body1 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
body2 = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
button = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
),
|
||||
caption = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun SplashView() {
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
// Image(
|
||||
// painter = painterResource(R.drawable.logo),
|
||||
// contentDescription = "Simplex Icon",
|
||||
// modifier = Modifier
|
||||
// .height(230.dp)
|
||||
// .align(Alignment.Center)
|
||||
// )
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.chat.SendMsgView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
||||
BackHandler(onBack = close)
|
||||
TerminalLayout(
|
||||
chatModel.terminalItems,
|
||||
composeState,
|
||||
sendCommand = {
|
||||
withApi {
|
||||
// show "in progress"
|
||||
chatModel.controller.sendCmd(CC.Console(composeState.value.message))
|
||||
composeState.value = ComposeState(useLinkPreviews = false)
|
||||
// hide "in progress"
|
||||
}
|
||||
},
|
||||
close
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TerminalLayout(
|
||||
terminalItems: List<TerminalItem>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
sendCommand: () -> Unit,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
|
||||
fun onMessageChange(s: String) {
|
||||
composeState.value = composeState.value.copy(message = s)
|
||||
}
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Scaffold(
|
||||
topBar = { CloseSheetBar(close) },
|
||||
bottomBar = {
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
TerminalLog(terminalItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
LazyColumn(state = listState) {
|
||||
items(terminalItems) { item ->
|
||||
Text("${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,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.clickable {
|
||||
ModalManager.shared.showModal {
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(item.details)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val len = terminalItems.count()
|
||||
if (len > 1) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(len - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewTerminalLayout() {
|
||||
SimpleXTheme {
|
||||
TerminalLayout(
|
||||
terminalItems = TerminalItem.sampleData,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
|
||||
sendCommand = {},
|
||||
close = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexService
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
|
||||
fun isValidDisplayName(name: String) : Boolean {
|
||||
return (name.firstOrNull { it.isWhitespace() }) == null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProfilePanel(chatModel: ChatModel) {
|
||||
val displayName = remember { mutableStateOf("") }
|
||||
val fullName = remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.create_profile),
|
||||
style = MaterialTheme.typography.h4,
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
)
|
||||
ReadableText(R.string.your_profile_is_stored_on_your_device)
|
||||
ReadableText(R.string.profile_is_only_shared_with_your_contacts)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
stringResource(R.string.display_name),
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
ProfileNameField(displayName, focusRequester)
|
||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
stringResource(R.string.full_name_optional__prompt),
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
SimpleButton(
|
||||
text = stringResource(R.string.about_simplex),
|
||||
icon = Icons.Outlined.ArrowBackIosNew
|
||||
) { chatModel.onboardingStage.value = 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 { createProfile(chatModel, displayName.value, fullName.value) }.padding(8.dp)
|
||||
createColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
createModifier = Modifier.padding(8.dp)
|
||||
createColor = HighOrLowlight
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor)
|
||||
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
|
||||
withApi {
|
||||
val user = chatModel.controller.apiCreateActiveUser(
|
||||
Profile(displayName, fullName, null)
|
||||
)
|
||||
chatModel.controller.startChat(user)
|
||||
SimplexService.start(chatModel.controller.appContext)
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileNameField(name: MutableState<String>, focusRequester: FocusRequester? = null) {
|
||||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.secondary)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.padding(8.dp)
|
||||
.navigationBarsWithImePadding()
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(HighOrLowlight)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.util.Log
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class CallManager(val chatModel: ChatModel) {
|
||||
fun reportNewIncomingCall(invitation: CallInvitation) {
|
||||
Log.d(TAG, "CallManager.reportNewIncomingCall")
|
||||
with (chatModel) {
|
||||
callInvitations[invitation.contact.id] = invitation
|
||||
if (!chatModel.controller.appPrefs.experimentalCalls.get()) return
|
||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||
activeCallInvitation.value = invitation
|
||||
controller.ntfManager.notifyCallInvitation(invitation)
|
||||
} else {
|
||||
val contact = invitation.contact
|
||||
controller.ntfManager.notifyMessageReceived(chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptIncomingCall(invitation: CallInvitation) {
|
||||
ModalManager.shared.closeModals()
|
||||
val call = chatModel.activeCall.value
|
||||
if (call == null) {
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
} else {
|
||||
withApi {
|
||||
chatModel.switchingCall.value = true
|
||||
try {
|
||||
endCall(call = call)
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
} finally {
|
||||
withApi { chatModel.switchingCall.value = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun justAcceptIncomingCall(invitation: CallInvitation) {
|
||||
with (chatModel) {
|
||||
activeCall.value = Call(
|
||||
contact = invitation.contact,
|
||||
callState = CallState.InvitationAccepted,
|
||||
localMedia = invitation.peerMedia,
|
||||
sharedKey = invitation.sharedKey
|
||||
)
|
||||
showCallView.value = true
|
||||
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
|
||||
callCommand.value = WCallCommand.Start (media = invitation.peerMedia, aesKey = invitation.sharedKey, relay = useRelay)
|
||||
callInvitations.remove(invitation.contact.id)
|
||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||
activeCallInvitation.value = null
|
||||
controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun endCall(call: Call) {
|
||||
with (chatModel) {
|
||||
if (call.callState == CallState.Ended) {
|
||||
Log.d(TAG, "CallManager.endCall: call ended")
|
||||
activeCall.value = null
|
||||
showCallView.value = false
|
||||
} else {
|
||||
Log.d(TAG, "CallManager.endCall: ending call...")
|
||||
callCommand.value = WCallCommand.End
|
||||
showCallView.value = false
|
||||
controller.apiEndCall(call.contact)
|
||||
activeCall.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun endCall(invitation: CallInvitation) {
|
||||
with (chatModel) {
|
||||
callInvitations.remove(invitation.contact.id)
|
||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||
activeCallInvitation.value = null
|
||||
controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
withApi {
|
||||
if (!controller.apiRejectCall(invitation.contact)) {
|
||||
Log.e(TAG, "apiRejectCall error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reportCallRemoteEnded(invitation: CallInvitation) {
|
||||
if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.Manifest
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import androidx.webkit.WebViewClientCompat
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@Composable
|
||||
fun ActiveCallView(chatModel: ChatModel) {
|
||||
BackHandler(onBack = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||
})
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||
Log.d(TAG, "received from WebRTCView: $apiMsg")
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) {
|
||||
Log.d(TAG, "has active call $call")
|
||||
when (val r = apiMsg.resp) {
|
||||
is WCallResponse.Capabilities -> withApi {
|
||||
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 {
|
||||
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 {
|
||||
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
}
|
||||
is WCallResponse.Ice -> withApi {
|
||||
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
|
||||
}
|
||||
is WCallResponse.Connection ->
|
||||
try {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||
}
|
||||
withApi { 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.Ended -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
||||
withApi { 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.value = 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) ActiveCallOverlay(call, chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
||||
ActiveCallOverlayLayout(
|
||||
call = call,
|
||||
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) },
|
||||
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallOverlayLayout(
|
||||
call: Call,
|
||||
dismiss: () -> Unit,
|
||||
toggleAudio: () -> Unit,
|
||||
toggleVideo: () -> Unit,
|
||||
flipCamera: () -> Unit
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
when (call.peerMedia ?: call.localMedia) {
|
||||
CallMediaType.Video -> {
|
||||
CallInfoView(call, alignment = Alignment.Start)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
ToggleAudioButton(call, toggleAudio)
|
||||
Spacer(Modifier.size(40.dp))
|
||||
IconButton(onClick = dismiss) {
|
||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
if (call.videoEnabled) {
|
||||
ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
|
||||
ControlButton(call, Icons.Filled.Videocam, R.string.icon_descr_video_off, toggleVideo)
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
|
||||
}
|
||||
}
|
||||
}
|
||||
CallMediaType.Audio -> {
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
ProfileImage(size = 192.dp, image = call.contact.profile.image)
|
||||
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), contentAlignment = Alignment.CenterStart) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
IconButton(onClick = dismiss) {
|
||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(start = 32.dp)) {
|
||||
ToggleAudioButton(call, toggleAudio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
|
||||
if (call.hasMedia) {
|
||||
IconButton(onClick = action) {
|
||||
Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), modifier = Modifier.size(40.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||
if (call.audioEnabled) {
|
||||
ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_video_off, toggleAudio)
|
||||
} else {
|
||||
ControlButton(call, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
|
||||
Text(text, color = Color(0xFFFFFFD8), style = style)
|
||||
Column(horizontalAlignment = alignment) {
|
||||
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
InfoText(call.callState.text)
|
||||
|
||||
val connInfo =
|
||||
if (call.connectionInfo == null) ""
|
||||
else " (${call.connectionInfo.text})"
|
||||
InfoText(call.encryptionStatus + connInfo)
|
||||
}
|
||||
}
|
||||
|
||||
//@Composable
|
||||
//fun CallViewDebug(close: () -> Unit) {
|
||||
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
|
||||
// val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
|
||||
// val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
|
||||
//
|
||||
// BackHandler(onBack = close)
|
||||
// Column(
|
||||
// horizontalAlignment = Alignment.CenterHorizontally,
|
||||
// verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
// modifier = Modifier
|
||||
// .background(MaterialTheme.colors.background)
|
||||
// .fillMaxSize()
|
||||
// ) {
|
||||
// WebRTCView(callCommand) { apiMsg ->
|
||||
// // for debugging
|
||||
// // commandText.value = apiMsg
|
||||
// commandText.value = json.encodeToString(apiMsg)
|
||||
// }
|
||||
//
|
||||
// TextEditor(Modifier.height(180.dp), text = commandText)
|
||||
//
|
||||
// Row(
|
||||
// Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(bottom = 6.dp),
|
||||
// horizontalArrangement = Arrangement.SpaceBetween
|
||||
// ) {
|
||||
// Button(onClick = {
|
||||
// val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
|
||||
// clipboard?.setPrimaryClip(clip)
|
||||
// }) { Text("Copy") }
|
||||
// Button(onClick = {
|
||||
// try {
|
||||
// val apiCall: WVAPICall = json.decodeFromString(commandText.value)
|
||||
// commandText.value = ""
|
||||
// println("sending: ${commandText.value}")
|
||||
// callCommand.value = apiCall.command
|
||||
// } catch(e: Error) {
|
||||
// println("error parsing command: ${commandText.value}")
|
||||
// println(e)
|
||||
// }
|
||||
// }) { Text("Send") }
|
||||
// Button(onClick = {
|
||||
// commandText.value = ""
|
||||
// }) { Text("Clear") }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@Composable
|
||||
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
Manifest.permission.MODIFY_AUDIO_SETTINGS,
|
||||
Manifest.permission.INTERNET
|
||||
)
|
||||
)
|
||||
fun processCommand(wv: WebView, cmd: WCallCommand) {
|
||||
val apiCall = WVAPICall(command = cmd)
|
||||
wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null)
|
||||
}
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) {
|
||||
permissionsState.launchMultiplePermissionRequest()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
val wv = webView.value
|
||||
if (wv != null) processCommand(wv, WCallCommand.End)
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
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 assetLoader = WebViewAssetLoader.Builder()
|
||||
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current))
|
||||
.build()
|
||||
|
||||
if (permissionsState.allPermissionsGranted) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = { AndroidViewContext ->
|
||||
WebView(AndroidViewContext).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
this.webChromeClient = object: WebChromeClient() {
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
if (request.origin.toString().startsWith("file:/")) {
|
||||
request.grant(request.resources)
|
||||
} else {
|
||||
Log.d(TAG, "Permission request from webview denied.")
|
||||
request.deny()
|
||||
}
|
||||
}
|
||||
}
|
||||
this.webViewClient = LocalContentWebViewClient(assetLoader)
|
||||
this.clearHistory()
|
||||
this.clearCache(true)
|
||||
this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
|
||||
val webViewSettings = this.settings
|
||||
webViewSettings.allowFileAccess = true
|
||||
webViewSettings.allowContentAccess = true
|
||||
webViewSettings.javaScriptEnabled = true
|
||||
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
||||
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
this.loadUrl("file:android_asset/www/call.html")
|
||||
}
|
||||
}
|
||||
) { wv ->
|
||||
Log.d(TAG, "WebRTCView: webview ready")
|
||||
// for debugging
|
||||
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||
withApi {
|
||||
delay(2000L)
|
||||
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||
webView.value = wv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// for debugging
|
||||
// class WebRTCInterface(private val onResponse: (String) -> Unit) {
|
||||
class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
|
||||
@JavascriptInterface
|
||||
fun postMessage(message: String) {
|
||||
Log.d(TAG, "WebRTCInterface.postMessage")
|
||||
try {
|
||||
// for debugging
|
||||
// onResponse(message)
|
||||
onResponse(json.decodeFromString(message))
|
||||
} catch (e: Error) {
|
||||
Log.e(TAG, "failed parsing WebView message: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
return assetLoader.shouldInterceptRequest(request.url)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewActiveCallOverlayVideo() {
|
||||
SimpleXTheme {
|
||||
ActiveCallOverlayLayout(
|
||||
call = Call(
|
||||
contact = Contact.sampleData,
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Video,
|
||||
peerMedia = CallMediaType.Video,
|
||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||
),
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
toggleVideo = {},
|
||||
flipCamera = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewActiveCallOverlayAudio() {
|
||||
SimpleXTheme {
|
||||
ActiveCallOverlayLayout(
|
||||
call = Call(
|
||||
contact = Contact.sampleData,
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Audio,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
||||
),
|
||||
dismiss = {},
|
||||
toggleAudio = {},
|
||||
toggleVideo = {},
|
||||
flipCamera = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.onboarding.SimpleXLogo
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class IncomingCallActivity: ComponentActivity() {
|
||||
private val vm by viewModels<SimplexViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val activity = this
|
||||
setContent { IncomingCallActivityView(vm.chatModel, activity) }
|
||||
unlockForIncomingCall()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
lockAfterIncomingCall()
|
||||
}
|
||||
|
||||
private fun unlockForIncomingCall() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
} else {
|
||||
window.addFlags(activityFlags)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getKeyguardManager(this).requestDismissKeyguard(this, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lockAfterIncomingCall() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(false)
|
||||
setTurnScreenOn(false)
|
||||
} else {
|
||||
window.clearFlags(activityFlags)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
|
||||
}
|
||||
}
|
||||
|
||||
fun getKeyguardManager(context: Context): KeyguardManager =
|
||||
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
|
||||
@Composable
|
||||
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
||||
val switchingCall = m.switchingCall.value
|
||||
val invitation = m.activeCallInvitation.value
|
||||
val call = m.activeCall.value
|
||||
val showCallView = m.showCallView.value
|
||||
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
||||
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
||||
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
||||
activity.finish()
|
||||
}
|
||||
}
|
||||
SimpleXTheme {
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()) {
|
||||
if (showCallView) {
|
||||
Box {
|
||||
ActiveCallView(m)
|
||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||
}
|
||||
} else if (invitation != null) {
|
||||
IncomingCallLockScreenAlert(invitation, m, activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
|
||||
val cm = chatModel.callManager
|
||||
val cxt = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
||||
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
|
||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation,
|
||||
callOnLockScreen,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||
openApp = {
|
||||
SoundPlayer.shared.stop()
|
||||
var intent = Intent(activity, MainActivity::class.java)
|
||||
.setAction(OpenChatAction)
|
||||
.putExtra("chatId", invitation.contact.id)
|
||||
activity.startActivity(intent)
|
||||
activity.finish()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallLockScreenAlertLayout(
|
||||
invitation: CallInvitation,
|
||||
callOnLockScreen: CallOnLockScreen?,
|
||||
rejectCall: () -> Unit,
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit,
|
||||
openApp: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(30.dp)
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
IncomingCallInfo(invitation)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
|
||||
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
|
||||
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
LockScreenCallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
LockScreenCallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
LockScreenCallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
||||
}
|
||||
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
||||
SimpleXLogo()
|
||||
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
||||
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
SimpleButton(text = stringResource(R.string.open_verb), icon = Icons.Filled.Check, click = openApp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 50.dp)
|
||||
.padding(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
IconButton(action) {
|
||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true
|
||||
)
|
||||
@Composable
|
||||
fun PreviewIncomingCallLockScreenAlert() {
|
||||
SimpleXTheme(true) {
|
||||
Surface(
|
||||
Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()) {
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation = CallInvitation(
|
||||
contact = Contact.sampleData,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
sharedKey = null,
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
callOnLockScreen = null,
|
||||
rejectCall = {},
|
||||
ignoreCall = {},
|
||||
acceptCall = {},
|
||||
openApp = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Contact
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.usersettings.ProfilePreview
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun IncomingCallAlertView(invitation: CallInvitation, chatModel: ChatModel) {
|
||||
val cm = chatModel.callManager
|
||||
val cxt = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = !chatModel.showCallView.value) }
|
||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||
IncomingCallAlertLayout(
|
||||
invitation,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallAlertLayout(
|
||||
invitation: CallInvitation,
|
||||
rejectCall: () -> Unit,
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit
|
||||
) {
|
||||
val color = if (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||
Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
|
||||
IncomingCallInfo(invitation)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
||||
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
||||
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallInfo(invitation: CallInvitation) {
|
||||
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
||||
Row {
|
||||
if (invitation.peerMedia == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
|
||||
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(invitation.callTypeText)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.clickable(onClick = action)
|
||||
.defaultMinSize(minWidth = 50.dp)
|
||||
.padding(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
|
||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewIncomingCallAlertLayout() {
|
||||
SimpleXTheme {
|
||||
IncomingCallAlertLayout(
|
||||
invitation = CallInvitation(
|
||||
contact = Contact.sampleData,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
sharedKey = null,
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
rejectCall = {},
|
||||
ignoreCall = {},
|
||||
acceptCall = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaPlayer
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.helpers.withScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class SoundPlayer {
|
||||
var player: MediaPlayer? = null
|
||||
var playing = false
|
||||
|
||||
fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
|
||||
if (sound) player = MediaPlayer.create(cxt, R.raw.ring_once)
|
||||
val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java)
|
||||
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
playing = true
|
||||
withScope(scope) {
|
||||
while (playing) {
|
||||
if (sound) player?.start()
|
||||
vibrator?.vibrate(effect)
|
||||
delay(3500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
playing = false
|
||||
player?.stop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = SoundPlayer()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.Contact
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
data class Call(
|
||||
val contact: Contact,
|
||||
val callState: CallState,
|
||||
val localMedia: CallMediaType,
|
||||
val localCapabilities: CallCapabilities? = null,
|
||||
val peerMedia: CallMediaType? = null,
|
||||
val sharedKey: String? = null,
|
||||
val audioEnabled: Boolean = true,
|
||||
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
|
||||
var localCamera: VideoCamera = VideoCamera.User,
|
||||
val connectionInfo: ConnectionInfo? = null
|
||||
) {
|
||||
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
||||
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||
|
||||
val encryptionStatus: String @Composable get() = when(callState) {
|
||||
CallState.WaitCapabilities -> ""
|
||||
CallState.InvitationSent -> stringResource(if (localEncrypted) R.string.status_e2e_encrypted else R.string.status_no_e2e_encryption)
|
||||
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption)
|
||||
else -> stringResource(if (!localEncrypted) R.string.status_no_e2e_encryption else if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_e2e_encrypted)
|
||||
}
|
||||
|
||||
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
||||
}
|
||||
|
||||
enum class CallState {
|
||||
WaitCapabilities,
|
||||
InvitationSent,
|
||||
InvitationAccepted,
|
||||
OfferSent,
|
||||
OfferReceived,
|
||||
AnswerReceived,
|
||||
Negotiated,
|
||||
Connected,
|
||||
Ended;
|
||||
|
||||
val text: String @Composable get() = when(this) {
|
||||
WaitCapabilities -> stringResource(R.string.callstate_starting)
|
||||
InvitationSent -> stringResource(R.string.callstate_waiting_for_answer)
|
||||
InvitationAccepted -> stringResource(R.string.callstate_starting)
|
||||
OfferSent -> stringResource(R.string.callstate_waiting_for_confirmation)
|
||||
OfferReceived -> stringResource(R.string.callstate_received_answer)
|
||||
AnswerReceived -> stringResource(R.string.callstate_received_confirmation)
|
||||
Negotiated -> stringResource(R.string.callstate_connecting)
|
||||
Connected -> stringResource(R.string.callstate_connected)
|
||||
Ended -> stringResource(R.string.callstate_ended)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
|
||||
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
|
||||
|
||||
@Serializable
|
||||
sealed class WCallCommand {
|
||||
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
|
||||
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand()
|
||||
@Serializable @SerialName("end") object End: WCallCommand()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class WCallResponse {
|
||||
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
|
||||
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
|
||||
@Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
||||
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
||||
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
|
||||
}
|
||||
|
||||
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
|
||||
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
||||
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
|
||||
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||
@Serializable class CallInvitation(val contact: Contact, val peerMedia: CallMediaType, val sharedKey: String?, val callTs: Instant) {
|
||||
val callTypeText: String get() = generalGetString(when(peerMedia) {
|
||||
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||
})
|
||||
val callTitle: String get() = generalGetString(when(peerMedia) {
|
||||
CallMediaType.Video -> R.string.incoming_video_call
|
||||
CallMediaType.Audio -> R.string.incoming_audio_call
|
||||
})
|
||||
}
|
||||
@Serializable class CallCapabilities(val encryption: Boolean)
|
||||
@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||
val text: String @Composable get() = when {
|
||||
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
|
||||
stringResource(R.string.call_connection_peer_to_peer)
|
||||
localCandidate?.candidateType == RTCIceCandidateType.Relay && remoteCandidate?.candidateType == RTCIceCandidateType.Relay ->
|
||||
stringResource(R.string.call_connection_via_relay)
|
||||
else ->
|
||||
"${localCandidate?.candidateType?.value ?: "unknown"} / ${remoteCandidate?.candidateType?.value ?: "unknown"}"
|
||||
}
|
||||
}
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
|
||||
@Serializable class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
|
||||
@Serializable
|
||||
enum class RTCIceCandidateType(val value: String) {
|
||||
@SerialName("host") Host("host"),
|
||||
@SerialName("srflx") ServerReflexive("srflx"),
|
||||
@SerialName("prflx") PeerReflexive("prflx"),
|
||||
@SerialName("relay") Relay("relay")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class WebRTCCallStatus(val value: String) {
|
||||
@SerialName("connected") Connected("connected"),
|
||||
@SerialName("connecting") Connecting("connecting"),
|
||||
@SerialName("disconnected") Disconnected("disconnected"),
|
||||
@SerialName("failed") Failed("failed")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CallMediaType {
|
||||
@SerialName("video") Video,
|
||||
@SerialName("audio") Audio
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class VideoCamera {
|
||||
@SerialName("user") User,
|
||||
@SerialName("environment") Environment;
|
||||
val flipped: VideoCamera get() = if (this == User) Environment else User
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ConnectionState(
|
||||
val connectionState: String,
|
||||
val iceConnectionState: String,
|
||||
val iceGatheringState: String,
|
||||
val signalingState: String
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
if (chat != null) {
|
||||
ChatInfoLayout(
|
||||
chat,
|
||||
close = close,
|
||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.delete_contact__question),
|
||||
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(chatInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.clear_chat_question),
|
||||
text = generalGetString(R.string.clear_chat_warning),
|
||||
confirmText = generalGetString(R.string.clear_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (updatedChatInfo != null) {
|
||||
chatModel.clearChat(updatedChatInfo)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoLayout(
|
||||
chat: Chat,
|
||||
close: () -> Unit,
|
||||
deleteContact: () -> Unit,
|
||||
clearChat: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CloseSheetBar(close)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
val cInfo = chat.chatInfo
|
||||
ChatInfoImage(cInfo, size = 192.dp)
|
||||
Text(
|
||||
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier
|
||||
.padding(top = 32.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(Modifier.padding(horizontal = 32.dp)) {
|
||||
ServerImage(chat)
|
||||
Text(
|
||||
chat.serverInfo.networkStatus.statusString,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
chat.serverInfo.networkStatus.statusExplanation,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1F))
|
||||
|
||||
Box(Modifier.padding(4.dp)) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.clear_chat_button),
|
||||
icon = Icons.Outlined.Restore,
|
||||
color = WarningOrange,
|
||||
click = clearChat
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.padding(4.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.button_delete_contact),
|
||||
icon = Icons.Outlined.Delete,
|
||||
color = Color.Red,
|
||||
click = deleteContact
|
||||
)
|
||||
}
|
||||
} else if (cInfo is ChatInfo.Group) {
|
||||
Spacer(Modifier.weight(1F))
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.padding(4.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.clear_chat_button),
|
||||
icon = Icons.Outlined.Restore,
|
||||
color = WarningOrange,
|
||||
click = clearChat
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerImage(chat: Chat) {
|
||||
when (chat.serverInfo.networkStatus) {
|
||||
is Chat.NetworkStatus.Connected ->
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||
is Chat.NetworkStatus.Disconnected ->
|
||||
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
||||
is Chat.NetworkStatus.Error ->
|
||||
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
||||
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatInfoLayout() {
|
||||
SimpleXTheme {
|
||||
ChatInfoLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf(),
|
||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||
),
|
||||
close = {}, deleteContact = {}, clearChat = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.mapSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.chat.item.ChatItemView
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatView(chatModel: ChatModel) {
|
||||
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
||||
val user = chatModel.currentUser.value
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val enableCalls = chatModel.controller.appPrefs.experimentalCalls.get()
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
|
||||
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
|
||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (chat == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
BackHandler { chatModel.chatId.value = null }
|
||||
// TODO a more advanced version would mark as read only if in view
|
||||
LaunchedEffect(chat.chatItems) {
|
||||
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
|
||||
delay(750L)
|
||||
if (chat.chatItems.isNotEmpty()) {
|
||||
chatModel.markChatItemsRead(chat.chatInfo)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
withApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatLayout(
|
||||
user,
|
||||
chat,
|
||||
composeState,
|
||||
composeView = {
|
||||
ComposeView(
|
||||
chatModel, chat, composeState, attachmentOption,
|
||||
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
|
||||
)
|
||||
},
|
||||
attachmentOption,
|
||||
scope,
|
||||
attachmentBottomSheetState,
|
||||
chatModel.chatItems,
|
||||
useLinkPreviews = useLinkPreviews,
|
||||
enableCalls = enableCalls,
|
||||
back = { chatModel.chatId.value = null },
|
||||
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
|
||||
openDirectChat = { contactId ->
|
||||
val c = chatModel.chats.firstOrNull {
|
||||
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
|
||||
}
|
||||
if (c != null) withApi { openChat(c.chatInfo, chatModel) }
|
||||
},
|
||||
deleteMessage = { itemId, mode ->
|
||||
withApi {
|
||||
val cInfo = chat.chatInfo
|
||||
val toItem = chatModel.controller.apiDeleteChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = itemId,
|
||||
mode = mode
|
||||
)
|
||||
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
|
||||
}
|
||||
},
|
||||
receiveFile = { fileId ->
|
||||
withApi { chatModel.controller.receiveFile(fileId) }
|
||||
},
|
||||
startCall = { media ->
|
||||
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
|
||||
}
|
||||
},
|
||||
acceptCall = { contact ->
|
||||
val invitation = chatModel.callInvitations.remove(contact.id)
|
||||
if (invitation == null) {
|
||||
AlertManager.shared.showAlertMsg("Call already ended!")
|
||||
} else {
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatLayout(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
composeState: MutableState<ComposeState>,
|
||||
composeView: (@Composable () -> Unit),
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
scope: CoroutineScope,
|
||||
attachmentBottomSheetState: ModalBottomSheetState,
|
||||
chatItems: List<ChatItem>,
|
||||
useLinkPreviews: Boolean,
|
||||
enableCalls: Boolean = false,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
acceptCall: (Contact) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
ChooseAttachmentView(
|
||||
attachmentOption,
|
||||
hide = { scope.launch { attachmentBottomSheetState.hide() } }
|
||||
)
|
||||
},
|
||||
sheetState = attachmentBottomSheetState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { ChatInfoToolbar(chat, enableCalls, back, info, startCall) },
|
||||
bottomBar = composeView,
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, acceptCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoToolbar(chat: Chat, enableCalls: Boolean, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
|
||||
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
|
||||
IconButton(onClick, modifier = modifier) {
|
||||
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val cInfo = chat.chatInfo
|
||||
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
|
||||
if (cInfo is ChatInfo.Direct && enableCalls) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
|
||||
toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
|
||||
startCall(CallMediaType.Audio)
|
||||
}
|
||||
}
|
||||
toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
|
||||
startCall(CallMediaType.Video)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.padding(horizontal = 80.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = info),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 40.dp)
|
||||
Column(
|
||||
Modifier.padding(start = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||
Text(
|
||||
cInfo.fullName,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
|
||||
|
||||
val CIListStateSaver = run {
|
||||
val scrolledKey = "scrolled"
|
||||
val countKey = "itemCount"
|
||||
val keyboardKey = "keyboardState"
|
||||
mapSaver(
|
||||
save = { mapOf(scrolledKey to it.scrolled, countKey to it.itemCount, keyboardKey to it.keyboardState) },
|
||||
restore = { CIListState(it[scrolledKey] as Boolean, it[countKey] as Int, it[keyboardKey] as KeyboardState) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemsList(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
composeState: MutableState<ComposeState>,
|
||||
chatItems: List<ChatItem>,
|
||||
useLinkPreviews: Boolean,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit
|
||||
) {
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
|
||||
val keyboardState by getKeyboardState()
|
||||
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
|
||||
mutableStateOf(CIListState(false, chatItems.count(), keyboardState))
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val cxt = LocalContext.current
|
||||
LazyColumn(state = listState) {
|
||||
itemsIndexed(chatItems) { i, cItem ->
|
||||
if (i == 0) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val prevItem = if (i > 0) chatItems[i - 1] else null
|
||||
val member = cItem.chatDir.groupMember
|
||||
val showMember = showMemberImage(member, prevItem)
|
||||
Row(Modifier.padding(start = 8.dp, end = 66.dp)) {
|
||||
if (showMember) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId == null) {
|
||||
MemberImage(member)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable { openDirectChat(contactId) }
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
val sent = cItem.chatDir.sent
|
||||
Box(
|
||||
Modifier.padding(
|
||||
start = if (sent) 76.dp else 12.dp,
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
)
|
||||
) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
val len = chatItems.count()
|
||||
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
|
||||
scope.launch {
|
||||
ciListState.value = CIListState(true, len, keyboardState)
|
||||
listState.animateScrollToItem(len - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberImage(member: GroupMember) {
|
||||
ProfileImage(38.dp, member.memberProfile.image)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatLayout() {
|
||||
SimpleXTheme {
|
||||
val chatItems = listOf(
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
2, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getDeletedContentSampleData(3),
|
||||
ChatItem.getSampleData(
|
||||
4, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
5, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
useLinkPreviews = true,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewGroupChatLayout() {
|
||||
SimpleXTheme {
|
||||
val chatItems = listOf(
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.GroupSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
2, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getDeletedContentSampleData(3),
|
||||
ChatItem.getSampleData(
|
||||
4, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
5, CIDirection.GroupSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Group.sampleData,
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
useLinkPreviews = true,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
|
||||
@Composable
|
||||
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
||||
Row(
|
||||
Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.InsertDriveFile,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
Text(fileName)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (cancelEnabled) {
|
||||
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewComposeFileView() {
|
||||
SimpleXTheme {
|
||||
ComposeFileView(
|
||||
"test.txt",
|
||||
cancelFile = {},
|
||||
cancelEnabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
|
||||
@Composable
|
||||
fun ComposeImageView(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview image",
|
||||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.height(60.dp)
|
||||
.padding(end = 8.dp)
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (cancelEnabled) {
|
||||
IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import ComposeFileView
|
||||
import ComposeImageView
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.Reply
|
||||
import androidx.compose.runtime.*
|
||||
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.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
|
||||
sealed class ComposePreview {
|
||||
object NoPreview: ComposePreview()
|
||||
class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
class ImagePreview(val image: String): ComposePreview()
|
||||
class FilePreview(val fileName: String): ComposePreview()
|
||||
}
|
||||
|
||||
sealed class ComposeContextItem {
|
||||
object NoContextItem: ComposeContextItem()
|
||||
class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
|
||||
class EditingItem(val chatItem: ChatItem): ComposeContextItem()
|
||||
}
|
||||
|
||||
data class ComposeState(
|
||||
val message: String = "",
|
||||
val preview: ComposePreview = ComposePreview.NoPreview,
|
||||
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
|
||||
val inProgress: Boolean = false,
|
||||
val useLinkPreviews: Boolean
|
||||
) {
|
||||
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this (
|
||||
editingItem.content.text,
|
||||
chatItemPreview(editingItem),
|
||||
ComposeContextItem.EditingItem(editingItem),
|
||||
useLinkPreviews = useLinkPreviews
|
||||
)
|
||||
|
||||
val editing: Boolean
|
||||
get() =
|
||||
when (contextItem) {
|
||||
is ComposeContextItem.EditingItem -> true
|
||||
else -> false
|
||||
}
|
||||
val sendEnabled: () -> Boolean
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty()
|
||||
}
|
||||
hasContent && !inProgress
|
||||
}
|
||||
val linkPreviewAllowed: Boolean
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> useLinkPreviews
|
||||
}
|
||||
val linkPreview: LinkPreview?
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.CLinkPreview -> preview.linkPreview
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
return when (val mc = chatItem.content.msgContent) {
|
||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image)
|
||||
is MsgContent.MCFile -> {
|
||||
val fileName = chatItem.file?.fileName ?: ""
|
||||
ComposePreview.FilePreview(fileName)
|
||||
}
|
||||
else -> ComposePreview.NoPreview
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ComposeView(
|
||||
chatModel: ChatModel,
|
||||
chat: Chat,
|
||||
composeState: MutableState<ComposeState>,
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
showChooseAttachment: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val linkUrl = remember { mutableStateOf<String?>(null) }
|
||||
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
|
||||
val pendingLinkUrl = remember { mutableStateOf<String?>(null) }
|
||||
val cancelledLinks = remember { mutableSetOf<String>() }
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
// attachments
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val chosenFile = remember { mutableStateOf<Uri?>(null) }
|
||||
val photoUri = remember { mutableStateOf<Uri?>(null) }
|
||||
val photoTmpFile = remember { mutableStateOf<File?>(null) }
|
||||
|
||||
class ComposeTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
||||
@CallSuper
|
||||
override fun createIntent(context: Context, input: Void?): Intent {
|
||||
photoTmpFile.value = File.createTempFile("image", ".bmp", SimplexApp.context.filesDir)
|
||||
photoUri.value = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", photoTmpFile.value!!)
|
||||
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
.putExtra(MediaStore.EXTRA_OUTPUT, photoUri.value)
|
||||
}
|
||||
|
||||
override fun getSynchronousResult(
|
||||
context: Context,
|
||||
input: Void?
|
||||
): SynchronousResult<Bitmap?>? = null
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
|
||||
val photoUriVal = photoUri.value
|
||||
val photoTmpFileVal = photoTmpFile.value
|
||||
return if (resultCode == Activity.RESULT_OK && photoUriVal != null && photoTmpFileVal != null) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, photoUriVal)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
photoTmpFileVal.delete()
|
||||
bitmap
|
||||
} else {
|
||||
Log.e(TAG, "Getting image from camera cancelled or failed.")
|
||||
photoTmpFile.value?.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
|
||||
if (bitmap != null) {
|
||||
chosenImage.value = bitmap
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
|
||||
}
|
||||
}
|
||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
cameraLauncher.launch(null)
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
chosenImage.value = bitmap
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
|
||||
}
|
||||
}
|
||||
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
||||
val fileName = getFileName(SimplexApp.context, uri)
|
||||
if (fileName != null) {
|
||||
chosenFile.value = uri
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(attachmentOption.value) {
|
||||
when (attachmentOption.value) {
|
||||
AttachmentOption.TakePhoto -> {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||
cameraLauncher.launch(null)
|
||||
}
|
||||
else -> {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickImage -> {
|
||||
galleryLauncher.launch("image/*")
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.PickFile -> {
|
||||
filesLauncher.launch("*/*")
|
||||
attachmentOption.value = null
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSimplexLink(link: String): Boolean =
|
||||
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
|
||||
|
||||
fun parseMessage(msg: String): String? {
|
||||
val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) }
|
||||
val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
|
||||
return link?.text
|
||||
}
|
||||
|
||||
fun loadLinkPreview(url: String, wait: Long? = null) {
|
||||
if (pendingLinkUrl.value == url) {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
|
||||
withApi {
|
||||
if (wait != null) delay(wait)
|
||||
val lp = getLinkPreview(url)
|
||||
if (lp != null && pendingLinkUrl.value == url) {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp))
|
||||
pendingLinkUrl.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showLinkPreview(s: String) {
|
||||
prevLinkUrl.value = linkUrl.value
|
||||
linkUrl.value = parseMessage(s)
|
||||
val url = linkUrl.value
|
||||
if (url != null) {
|
||||
if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) {
|
||||
pendingLinkUrl.value = url
|
||||
loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L)
|
||||
}
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetLinkPreview() {
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
pendingLinkUrl.value = null
|
||||
cancelledLinks.clear()
|
||||
}
|
||||
|
||||
fun checkLinkPreview(): MsgContent {
|
||||
val cs = composeState.value
|
||||
return when (val composePreview = cs.preview) {
|
||||
is ComposePreview.CLinkPreview -> {
|
||||
val url = parseMessage(cs.message)
|
||||
val lp = composePreview.linkPreview
|
||||
if (lp != null && url == lp.uri) {
|
||||
MsgContent.MCLink(cs.message, preview = lp)
|
||||
} else {
|
||||
MsgContent.MCText(cs.message)
|
||||
}
|
||||
}
|
||||
else -> MsgContent.MCText(cs.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMsgContent(msgContent: MsgContent): MsgContent {
|
||||
val cs = composeState.value
|
||||
return when (msgContent) {
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
||||
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearState() {
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
textStyle.value = smallFont
|
||||
chosenImage.value = null
|
||||
chosenFile.value = null
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
pendingLinkUrl.value = null
|
||||
cancelledLinks.clear()
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
composeState.value = composeState.value.copy(inProgress = true)
|
||||
val cInfo = chat.chatInfo
|
||||
val cs = composeState.value
|
||||
when (val contextItem = cs.contextItem) {
|
||||
is ComposeContextItem.EditingItem -> {
|
||||
val ei = contextItem.chatItem
|
||||
val oldMsgContent = ei.content.msgContent
|
||||
if (oldMsgContent != null) {
|
||||
withApi {
|
||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = ei.meta.itemId,
|
||||
mc = updateMsgContent(oldMsgContent)
|
||||
)
|
||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
var mc: MsgContent? = null
|
||||
var file: String? = null
|
||||
when (val preview = cs.preview) {
|
||||
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
|
||||
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
|
||||
is ComposePreview.ImagePreview -> {
|
||||
val chosenImageVal = chosenImage.value
|
||||
if (chosenImageVal != null) {
|
||||
file = saveImage(context, chosenImageVal)
|
||||
if (file != null) {
|
||||
mc = MsgContent.MCImage(cs.message, preview.image)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
file = saveFileFromUri(context, chosenFileVal)
|
||||
if (file != null) {
|
||||
mc = MsgContent.MCFile(cs.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val quotedItemId: Long? = when (contextItem) {
|
||||
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (mc != null) {
|
||||
withApi {
|
||||
val aChatItem = chatModel.controller.apiSendMessage(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
file = file,
|
||||
quotedItemId = quotedItemId,
|
||||
mc = mc
|
||||
)
|
||||
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||
clearState()
|
||||
}
|
||||
} else {
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onMessageChange(s: String) {
|
||||
composeState.value = composeState.value.copy(message = s)
|
||||
if (isShortEmoji(s)) {
|
||||
textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
|
||||
} else {
|
||||
textStyle.value = smallFont
|
||||
if (composeState.value.linkPreviewAllowed) {
|
||||
if (s.isNotEmpty()) showLinkPreview(s)
|
||||
else resetLinkPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelLinkPreview() {
|
||||
val uri = composeState.value.linkPreview?.uri
|
||||
if (uri != null) {
|
||||
cancelledLinks.add(uri)
|
||||
}
|
||||
pendingLinkUrl.value = null
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
}
|
||||
|
||||
fun cancelImage() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenImage.value = null
|
||||
}
|
||||
|
||||
fun cancelFile() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenFile.value = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun previewView() {
|
||||
when (val preview = composeState.value.preview) {
|
||||
ComposePreview.NoPreview -> {}
|
||||
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
|
||||
is ComposePreview.ImagePreview -> ComposeImageView(
|
||||
preview.image,
|
||||
::cancelImage,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.FilePreview -> ComposeFileView(
|
||||
preview.fileName,
|
||||
::cancelFile,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun contextItemView() {
|
||||
when (val contextItem = composeState.value.contextItem) {
|
||||
ComposeContextItem.NoContextItem -> {}
|
||||
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||
}
|
||||
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) {
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
contextItemView()
|
||||
when {
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||
else -> previewView()
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
val attachEnabled = !composeState.value.editing
|
||||
Box(Modifier.padding(bottom = 12.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.AttachFile,
|
||||
contentDescription = stringResource(R.string.attach),
|
||||
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
if (attachEnabled) {
|
||||
showChooseAttachment()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
SendMsgView(
|
||||
composeState,
|
||||
sendMessage = {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
::onMessageChange,
|
||||
textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIDirection
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ContextItemView(
|
||||
contextItem: ChatItem,
|
||||
contextIcon: ImageVector,
|
||||
cancelContextItem: () -> Unit
|
||||
) {
|
||||
val sent = contextItem.chatDir.sent
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.background(if (sent) SentColorLight else ReceivedColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(vertical = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
contextIcon,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.height(20.dp)
|
||||
.width(20.dp),
|
||||
contentDescription = stringResource(R.string.icon_descr_context),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
MarkdownText(
|
||||
contextItem.text, contextItem.formattedText,
|
||||
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3
|
||||
)
|
||||
}
|
||||
IconButton(onClick = cancelContextItem) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.cancel_verb),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewContextItemView() {
|
||||
SimpleXTheme {
|
||||
ContextItemView(
|
||||
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
||||
contextIcon = Icons.Filled.Edit,
|
||||
cancelContextItem = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
||||
import androidx.compose.runtime.*
|
||||
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.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
composeState: MutableState<ComposeState>,
|
||||
sendMessage: () -> Unit,
|
||||
onMessageChange: (String) -> Unit,
|
||||
textStyle: MutableState<TextStyle>
|
||||
) {
|
||||
val cs = composeState.value
|
||||
BasicTextField(
|
||||
value = cs.message,
|
||||
onValueChange = onMessageChange,
|
||||
textStyle = textStyle.value,
|
||||
maxLines = 16,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
autoCorrect = true
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
cursorBrush = SolidColor(HighOrLowlight),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
) {
|
||||
Row(
|
||||
Modifier.background(MaterialTheme.colors.background),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 5.dp)
|
||||
.padding(bottom = 7.dp)
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (cs.inProgress
|
||||
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.clickable {
|
||||
if (cs.sendEnabled()) {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSendMsgView() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSendMsgViewEditing() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData(), useLinkPreviews = true)
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSendMsgViewInProgress() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true)
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PhoneInTalk
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
|
||||
val sent = cItem.chatDir.sent
|
||||
Column(
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
|
||||
when (status) {
|
||||
CICallStatus.Pending -> if (sent) {
|
||||
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
|
||||
} else {
|
||||
AcceptCallButton(cInfo, acceptCall)
|
||||
}
|
||||
CICallStatus.Missed -> Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
||||
CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
|
||||
CICallStatus.Accepted -> ConnectingCallIcon()
|
||||
CICallStatus.Negotiated -> ConnectingCallIcon()
|
||||
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
||||
CICallStatus.Ended -> Row {
|
||||
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(status.duration(duration), color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
cItem.timestampText,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
SimpleButton(stringResource(R.string.answer_call), Icons.Outlined.RingVolume) { acceptCall(cInfo.contact) }
|
||||
} else {
|
||||
Icon(Icons.Outlined.RingVolume, stringResource(R.string.answer_call), tint = HighOrLowlight)
|
||||
}
|
||||
// if case let .direct(contact) = chatInfo {
|
||||
// Button {
|
||||
// if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
|
||||
// m.activeCallInvitation = nil
|
||||
// m.activeCall = Call(
|
||||
// contact: contact,
|
||||
// callState: .invitationReceived,
|
||||
// localMedia: invitation.peerMedia,
|
||||
// sharedKey: invitation.sharedKey
|
||||
// )
|
||||
// m.showCallView = true
|
||||
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||
// } else {
|
||||
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||
// }
|
||||
// } label: {
|
||||
// Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||
// }
|
||||
// } else {
|
||||
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
//struct CICallItemView: View {
|
||||
// @EnvironmentObject var m: ChatModel
|
||||
// var chatInfo: ChatInfo
|
||||
// var chatItem: ChatItem
|
||||
// var status: CICallStatus
|
||||
// var duration: Int
|
||||
//
|
||||
// var body: some View {
|
||||
// switch status {
|
||||
// case .pending:
|
||||
// if sent {
|
||||
// Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
|
||||
// } else {
|
||||
// acceptCallButton()
|
||||
// }
|
||||
// case .missed: missedCallIcon(sent).foregroundColor(.red)
|
||||
// case .rejected: Image(systemName: "phone.down").foregroundColor(.secondary)
|
||||
// case .accepted: connectingCallIcon()
|
||||
// case .negotiated: connectingCallIcon()
|
||||
// case .progress: Image(systemName: "phone.and.waveform.fill").foregroundColor(.green)
|
||||
// case .ended: endedCallIcon(sent)
|
||||
// case .error: missedCallIcon(sent).foregroundColor(.orange)
|
||||
// }
|
||||
//
|
||||
// chatItem.timestampText
|
||||
// .font(.caption)
|
||||
// .foregroundColor(.secondary)
|
||||
// .padding(.bottom, 8)
|
||||
// .padding(.horizontal, 12)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func missedCallIcon(_ sent: Bool) -> some View {
|
||||
// Image(systemName: sent ? "phone.arrow.up.right" : "phone.arrow.down.left")
|
||||
// }
|
||||
//
|
||||
// private func connectingCallIcon() -> some View {
|
||||
// Image(systemName: "phone.connection").foregroundColor(.green)
|
||||
// }
|
||||
//
|
||||
// @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||
// HStack {
|
||||
// Image(systemName: "phone.down")
|
||||
// Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//
|
||||
// @ViewBuilder private func acceptCallButton() -> some View {
|
||||
// if case let .direct(contact) = chatInfo {
|
||||
// Button {
|
||||
// if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
|
||||
// m.activeCallInvitation = nil
|
||||
// m.activeCall = Call(
|
||||
// contact: contact,
|
||||
// callState: .invitationReceived,
|
||||
// localMedia: invitation.peerMedia,
|
||||
// sharedKey: invitation.sharedKey
|
||||
// )
|
||||
// m.showCallView = true
|
||||
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||
// } else {
|
||||
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||
// }
|
||||
// } label: {
|
||||
// Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||
// }
|
||||
// } else {
|
||||
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,211 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun CIFileView(
|
||||
file: CIFile?,
|
||||
edited: Boolean,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file)
|
||||
|
||||
@Composable
|
||||
fun fileIcon(
|
||||
innerIcon: ImageVector? = null,
|
||||
color: Color = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.InsertDriveFile,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = color
|
||||
)
|
||||
if (innerIcon != null) {
|
||||
Icon(
|
||||
innerIcon,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.size(32.dp)
|
||||
.padding(top = 12.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fileSizeValid(): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= MAX_FILE_SIZE
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun fileAction() {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation -> {
|
||||
if (fileSizeValid()) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
||||
)
|
||||
}
|
||||
}
|
||||
CIFileStatus.RcvAccepted ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_file),
|
||||
String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
|
||||
)
|
||||
CIFileStatus.RcvComplete -> {
|
||||
val filePath = getLoadedFilePath(context, file)
|
||||
if (filePath != null) {
|
||||
saveFileLauncher.launch(file.fileName)
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun progressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(32.dp),
|
||||
color = if (isSystemInDarkTheme()) FileDark else FileLight,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fileIndicator() {
|
||||
Box(
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = { fileAction() }),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.SndStored -> fileIcon()
|
||||
CIFileStatus.SndTransfer -> progressIndicator()
|
||||
CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
|
||||
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
CIFileStatus.RcvInvitation ->
|
||||
if (fileSizeValid())
|
||||
fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
|
||||
else
|
||||
fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
|
||||
CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
|
||||
CIFileStatus.RcvTransfer -> progressIndicator()
|
||||
CIFileStatus.RcvComplete -> fileIcon()
|
||||
CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
}
|
||||
} else {
|
||||
fileIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
fileIndicator()
|
||||
val metaReserve = if (edited)
|
||||
" "
|
||||
else
|
||||
" "
|
||||
if (file != null) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
file.fileName,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
formatBytes(file.fileSize) + metaReserve,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(metaReserve)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
private val sentFile = ChatItem(
|
||||
chatDir = CIDirection.DirectSnd(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false),
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
|
||||
quotedItem = null,
|
||||
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
|
||||
)
|
||||
private val fileChatItemWtFile = ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
override val values = listOf(
|
||||
sentFile,
|
||||
ChatItem.getFileMsgContentSample(),
|
||||
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
|
||||
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
|
||||
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 = CIFileStatus.RcvInvitation),
|
||||
fileChatItemWtFile
|
||||
).asSequence()
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SimpleXTheme {
|
||||
FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.app.model.CIFileStatus
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun CIImageView(
|
||||
image: String,
|
||||
file: CIFile?,
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
@Composable
|
||||
fun loadingIndicator() {
|
||||
if (file != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.size(20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.SndTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
CIFileStatus.SndComplete ->
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
stringResource(R.string.icon_descr_image_snd_complete),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
Icon(
|
||||
Icons.Outlined.MoreHoriz,
|
||||
stringResource(R.string.icon_descr_waiting_for_image),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
CIFileStatus.RcvTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
|
||||
Image(
|
||||
imageBitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
|
||||
// if text is short and take all available width if text is long
|
||||
modifier = Modifier
|
||||
.width(1000.dp)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = onClick
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
val context = LocalContext.current
|
||||
val imageBitmap: Bitmap? = getLoadedImage(context, file)
|
||||
if (imageBitmap != null) {
|
||||
imageView(imageBitmap, onClick = {
|
||||
if (getLoadedFilePath(context, file) != null) {
|
||||
ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
|
||||
}
|
||||
})
|
||||
} else {
|
||||
imageView(base64ToBitmap(image), onClick = {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation ->
|
||||
receiveFile(file.fileId)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.waiting_for_image),
|
||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||
)
|
||||
CIFileStatus.RcvTransfer -> {} // ?
|
||||
CIFileStatus.RcvComplete -> {} // ?
|
||||
CIFileStatus.RcvCancelled -> {} // TODO
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
loadingIndicator()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimplexBlue
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!chatItem.isDeletedContent) {
|
||||
if (chatItem.meta.itemEdited) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
modifier = Modifier.height(12.dp).padding(end = 1.dp),
|
||||
contentDescription = stringResource(R.string.icon_descr_edited),
|
||||
tint = metaColor,
|
||||
)
|
||||
}
|
||||
CIStatusView(chatItem.meta.itemStatus, metaColor)
|
||||
}
|
||||
Text(
|
||||
chatItem.timestampText,
|
||||
color = metaColor,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
|
||||
when (status) {
|
||||
is CIStatus.SndSent -> {
|
||||
Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
|
||||
}
|
||||
is CIStatus.SndErrorAuth -> {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
|
||||
}
|
||||
is CIStatus.SndError -> {
|
||||
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
|
||||
}
|
||||
is CIStatus.RcvNew -> {
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaView() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewUnread() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
status = CIStatus.RcvNew()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewSendFailed() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewSendNoAuth() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewSendSent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewEdited() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewEditedUnread() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status=CIStatus.RcvNew()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewEditedSent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status=CIStatus.SndSent()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewDeletedContent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ComposeContextItem
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatItemView(
|
||||
user: User,
|
||||
cInfo: ChatInfo,
|
||||
cItem: ChatItem,
|
||||
composeState: MutableState<ComposeState>,
|
||||
cxt: Context,
|
||||
uriHandler: UriHandler? = null,
|
||||
showMember: Boolean = false,
|
||||
useLinkPreviews: Boolean,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sent = cItem.chatDir.sent
|
||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
|
||||
) {
|
||||
@Composable fun ContentItem() {
|
||||
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
} else {
|
||||
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.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
|
||||
})
|
||||
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
||||
shareText(cxt, cItem.content.text)
|
||||
showMenu.value = false
|
||||
})
|
||||
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
||||
copyText(cxt, cItem.content.text)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
|
||||
val filePath = getLoadedFilePath(context, cItem.file)
|
||||
if (filePath != null) {
|
||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||
when (cItem.content.msgContent) {
|
||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
else -> {}
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
if (cItem.meta.editable) {
|
||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun DeletedItem() {
|
||||
DeletedItemView(cItem, showMember = showMember)
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun CallItem(status: CICallStatus, duration: Int) {
|
||||
CICallItemView(cInfo, cItem, status, duration, acceptCall)
|
||||
}
|
||||
|
||||
when (val c = cItem.content) {
|
||||
is CIContent.SndMsgContent -> ContentItem()
|
||||
is CIContent.RcvMsgContent -> ContentItem()
|
||||
is CIContent.SndDeleted -> DeletedItem()
|
||||
is CIContent.RcvDeleted -> DeletedItem()
|
||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
||||
DropdownMenuItem(onClick) {
|
||||
Row {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F),
|
||||
color = color
|
||||
)
|
||||
Icon(icon, text, tint = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(R.string.delete_message__question),
|
||||
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
|
||||
buttons = {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_me_only)) }
|
||||
if (chatItem.meta.editable) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Button(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_everybody)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemView() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
useLinkPreviews = true,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
acceptCall = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemViewDeletedContent() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
User.sampleData,
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
useLinkPreviews = true,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
acceptCall = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(ci.content.text) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.ChatItem
|
||||
|
||||
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
|
||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
|
||||
|
||||
@Composable
|
||||
fun EmojiItemView(chatItem: ChatItem) {
|
||||
Column(
|
||||
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EmojiText(chatItem.content.text)
|
||||
CIMetaView(chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiText(text: String) {
|
||||
val s = text.trim()
|
||||
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
|
||||
}
|
||||
|
||||
private fun isSimpleEmoji(c: Int): Boolean = c > 0x238C
|
||||
|
||||
fun isEmoji(c: Int): Boolean = isSimpleEmoji(c) // || isCombinedIntoEmoji(c)
|
||||
|
||||
// TODO count perceived emojis, possibly using icu4j
|
||||
fun isShortEmoji(str: String): Boolean {
|
||||
val s = str.trim()
|
||||
return s.codePoints().count() in 1..5 && s.codePoints().allMatch(::isEmoji)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,55 @@
|
||||
import android.graphics.Bitmap
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
|
||||
@Composable
|
||||
fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(onClick = close)
|
||||
) {
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var translationX by remember { mutableStateOf(0f) }
|
||||
var translationY by remember { mutableStateOf(0f) }
|
||||
Image(
|
||||
imageBitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
onGesture = { _, pan, gestureZoom, _ ->
|
||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||
if (scale > 1) {
|
||||
translationX += pan.x * scale
|
||||
translationY += pan.y * scale
|
||||
} else {
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
Surface(
|
||||
Modifier.clickable(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||
)
|
||||
}),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun IntegrityErrorItemViewView() {
|
||||
SimpleXTheme {
|
||||
IntegrityErrorItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
|
||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
||||
|
||||
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
|
||||
if (chatItem.chatDir is CIDirection.GroupRcv) {
|
||||
val name = chatItem.chatDir.groupMember.memberProfile.displayName
|
||||
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
|
||||
else b.append(name)
|
||||
b.append(": ")
|
||||
}
|
||||
}
|
||||
|
||||
fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
|
||||
if (sender != null) {
|
||||
if (senderBold) b.withStyle(boldFont) { append(sender) }
|
||||
else b.append(sender)
|
||||
b.append(": ")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
text: String,
|
||||
formattedText: List<FormattedText>? = null,
|
||||
sender: String? = null,
|
||||
metaText: String? = null,
|
||||
edited: Boolean = false,
|
||||
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
uriHandler: UriHandler? = null,
|
||||
senderBold: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val reserve = if (edited) " " else " "
|
||||
if (formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
append(text)
|
||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
}
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
} else {
|
||||
var hasLinks = false
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
for (ft in formattedText) {
|
||||
if (ft.format == null) append(ft.text)
|
||||
else {
|
||||
val link = ft.link
|
||||
if (link != null) {
|
||||
hasLinks = true
|
||||
withAnnotation(tag = "URL", annotation = link) {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
}
|
||||
} else {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
}
|
||||
if (hasLinks && uriHandler != null) {
|
||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.annotatedStringResource
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
|
||||
val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
||||
|
||||
@Composable
|
||||
fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
|
||||
Text(
|
||||
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
uriHandler.openUri(simplexTeamUri)
|
||||
}),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier.padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.to_start_a_new_chat_help_header),
|
||||
style = MaterialTheme.typography.h2,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.chat_help_tap_button))
|
||||
Icon(
|
||||
Icons.Outlined.PersonAdd,
|
||||
stringResource(R.string.add_contact),
|
||||
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
|
||||
)
|
||||
Text(stringResource(R.string.above_then_preposition_continuation))
|
||||
}
|
||||
Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp)
|
||||
Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
|
||||
Text(stringResource(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp)
|
||||
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
|
||||
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatHelpLayout() {
|
||||
SimpleXTheme {
|
||||
ChatHelpView {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.WarningOrange
|
||||
import chat.simplex.app.views.chat.clearChatDialog
|
||||
import chat.simplex.app.views.chat.deleteContactDialog
|
||||
import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
var showMarkRead by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) {
|
||||
showMenu.value = false
|
||||
delay(500L)
|
||||
showMarkRead = chat.chatStats.unreadCount > 0
|
||||
}
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat) },
|
||||
click = { openOrPendingChat(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||
showMenu
|
||||
)
|
||||
is ChatInfo.Group ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat) },
|
||||
click = { openOrPendingChat(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||
showMenu
|
||||
)
|
||||
is ChatInfo.ContactRequest ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ContactRequestView(chat.chatInfo) },
|
||||
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||
showMenu
|
||||
)
|
||||
is ChatInfo.ContactConnection ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
|
||||
click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) },
|
||||
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||
showMenu
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
if (chatInfo.ready) {
|
||||
withApi { openChat(chatInfo, chatModel) }
|
||||
} else {
|
||||
pendingContactAlertDialog(chatInfo, chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (chat != null) {
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(chat.chatItems)
|
||||
chatModel.chatId.value = chatInfo.id
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||
if (showMarkRead) {
|
||||
ItemAction(
|
||||
stringResource(R.string.mark_read),
|
||||
Icons.Outlined.Check,
|
||||
onClick = {
|
||||
markChatRead(chat, chatModel)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
ItemAction(
|
||||
stringResource(R.string.clear_verb),
|
||||
Icons.Outlined.Restore,
|
||||
onClick = {
|
||||
clearChatDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = WarningOrange
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
deleteContactDialog(chat.chatInfo as ChatInfo.Direct, chatModel)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||
if (showMarkRead) {
|
||||
ItemAction(
|
||||
stringResource(R.string.mark_read),
|
||||
Icons.Outlined.Check,
|
||||
onClick = {
|
||||
markChatRead(chat, chatModel)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
ItemAction(
|
||||
stringResource(R.string.clear_verb),
|
||||
Icons.Outlined.Restore,
|
||||
onClick = {
|
||||
clearChatDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = WarningOrange
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.accept_contact_button),
|
||||
Icons.Outlined.Check,
|
||||
onClick = {
|
||||
acceptContactRequest(chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
ItemAction(
|
||||
stringResource(R.string.reject_contact_button),
|
||||
Icons.Outlined.Close,
|
||||
onClick = {
|
||||
rejectContactRequest(chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
fun markChatRead(chat: Chat, chatModel: ChatModel) {
|
||||
chatModel.markChatItemsRead(chat.chatInfo)
|
||||
withApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.accept_connection_request__question),
|
||||
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
||||
confirmText = generalGetString(R.string.accept_contact_button),
|
||||
onConfirm = { acceptContactRequest(contactRequest, chatModel) },
|
||||
dismissText = generalGetString(R.string.reject_contact_button),
|
||||
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
|
||||
)
|
||||
}
|
||||
|
||||
fun acceptContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||
withApi {
|
||||
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
|
||||
if (contact != null) {
|
||||
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
||||
chatModel.replaceChat(contactRequest.id, chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||
withApi {
|
||||
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
|
||||
chatModel.removeChat(contactRequest.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(
|
||||
if (connection.initiated) R.string.you_invited_your_contact
|
||||
else R.string.you_accepted_connection
|
||||
),
|
||||
text = generalGetString(
|
||||
if (connection.viaContactUri) R.string.you_will_be_connected_when_your_connection_request_is_accepted
|
||||
else R.string.you_will_be_connected_when_your_contacts_device_is_online
|
||||
),
|
||||
buttons = {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = {
|
||||
AlertManager.shared.hideAlert()
|
||||
deleteContactConnectionAlert(connection, chatModel)
|
||||
}) {
|
||||
Text(stringResource(R.string.delete_verb))
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Button(onClick = { AlertManager.shared.hideAlert() }) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.delete_pending_connection__question),
|
||||
text = generalGetString(
|
||||
if (connection.initiated) R.string.contact_you_shared_link_with_wont_be_able_to_connect
|
||||
else R.string.connection_you_accepted_will_be_cancelled
|
||||
),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
AlertManager.shared.hideAlert()
|
||||
if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) {
|
||||
chatModel.removeChat(connection.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.alert_title_contact_connection_pending),
|
||||
text = generalGetString(R.string.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry),
|
||||
confirmText = generalGetString(R.string.button_delete_contact),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(chatInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissText = generalGetString(R.string.cancel_verb),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkLayout(
|
||||
chatLinkPreview: @Composable () -> Unit,
|
||||
click: () -> Unit,
|
||||
dropdownMenuItems: (@Composable () -> Unit)?,
|
||||
showMenu: MutableState<Boolean>
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = click,
|
||||
onLongClick = { showMenu.value = true }
|
||||
)
|
||||
.height(88.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(start = 8.dp)
|
||||
.padding(end = 12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
chatLinkPreview()
|
||||
}
|
||||
if (dropdownMenuItems != null) {
|
||||
Box(Modifier.padding(horizontal = 16.dp)) {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
dropdownMenuItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkDirect() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = {
|
||||
ChatPreviewView(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = listOf(
|
||||
ChatItem.getSampleData(
|
||||
1,
|
||||
CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
"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."
|
||||
)
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
)
|
||||
)
|
||||
},
|
||||
click = {},
|
||||
dropdownMenuItems = null,
|
||||
showMenu = remember { mutableStateOf(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkGroup() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = {
|
||||
ChatPreviewView(
|
||||
Chat(
|
||||
chatInfo = ChatInfo.Group.sampleData,
|
||||
chatItems = listOf(
|
||||
ChatItem.getSampleData(
|
||||
1,
|
||||
CIDirection.DirectSnd(),
|
||||
Clock.System.now(),
|
||||
"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."
|
||||
)
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
)
|
||||
)
|
||||
},
|
||||
click = {},
|
||||
dropdownMenuItems = null,
|
||||
showMenu = remember { mutableStateOf(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatListNavLinkContactRequest() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = {
|
||||
ContactRequestView(ChatInfo.ContactRequest.sampleData)
|
||||
},
|
||||
click = {},
|
||||
dropdownMenuItems = null,
|
||||
showMenu = remember { mutableStateOf(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.ToolbarDark
|
||||
import chat.simplex.app.ui.theme.ToolbarLight
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.onboarding.MakeConnection
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ScaffoldController(val scope: CoroutineScope) {
|
||||
lateinit var state: BottomSheetScaffoldState
|
||||
val expanded = mutableStateOf(false)
|
||||
|
||||
fun expand() {
|
||||
expanded.value = true
|
||||
scope.launch { state.bottomSheetState.expand() }
|
||||
}
|
||||
|
||||
fun collapse() {
|
||||
expanded.value = false
|
||||
scope.launch { state.bottomSheetState.collapse() }
|
||||
}
|
||||
|
||||
fun toggleSheet() {
|
||||
if (state.bottomSheetState.isExpanded) collapse() else expand()
|
||||
}
|
||||
|
||||
fun toggleDrawer() = scope.launch {
|
||||
state.drawerState.apply { if (isClosed) open() else close() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun scaffoldController(): ScaffoldController {
|
||||
val ctrl = ScaffoldController(scope = rememberCoroutineScope())
|
||||
val bottomSheetState = rememberBottomSheetState(
|
||||
BottomSheetValue.Collapsed,
|
||||
confirmStateChange = {
|
||||
ctrl.expanded.value = it == BottomSheetValue.Expanded
|
||||
true
|
||||
}
|
||||
)
|
||||
ctrl.state = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
val scaffoldCtrl = scaffoldController()
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
|
||||
}
|
||||
BottomSheetScaffold(
|
||||
scaffoldState = scaffoldCtrl.state,
|
||||
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||
sheetPeekHeight = 0.dp,
|
||||
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ChatListToolbar(scaffoldCtrl)
|
||||
Divider()
|
||||
if (chatModel.chats.isNotEmpty()) {
|
||||
ChatList(chatModel)
|
||||
} else {
|
||||
MakeConnection(chatModel)
|
||||
}
|
||||
}
|
||||
if (scaffoldCtrl.expanded.value) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { scaffoldCtrl.collapse() },
|
||||
color = Color.Black.copy(alpha = 0.12F)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
|
||||
Icon(
|
||||
Icons.Outlined.Menu,
|
||||
stringResource(R.string.icon_descr_settings),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.your_chats),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(5.dp)
|
||||
)
|
||||
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
|
||||
Icon(
|
||||
Icons.Outlined.PersonAdd,
|
||||
stringResource(R.string.add_contact),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatList(chatModel: ChatModel) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(chatModel.chats) { chat ->
|
||||
ChatListNavLinkView(chat, chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.MarkdownText
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
import chat.simplex.app.views.helpers.badgeLayout
|
||||
|
||||
@Composable
|
||||
fun ChatPreviewView(chat: Chat) {
|
||||
Row {
|
||||
val cInfo = chat.chatInfo
|
||||
ChatInfoImage(cInfo, size = 72.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1F)
|
||||
) {
|
||||
Text(
|
||||
cInfo.chatViewName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (cInfo.ready) Color.Unspecified else HighOrLowlight
|
||||
)
|
||||
if (cInfo.ready) {
|
||||
val ci = chat.chatItems.lastOrNull()
|
||||
if (ci != null) {
|
||||
MarkdownText(
|
||||
ci.text, ci.formattedText, ci.memberDisplayName,
|
||||
metaText = ci.timestampText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
Text(
|
||||
ts,
|
||||
color = HighOrLowlight,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
val n = chat.chatStats.unreadCount
|
||||
if (n > 0) {
|
||||
Box(
|
||||
Modifier.padding(top = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.badgeLayout()
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
Box(
|
||||
Modifier.padding(top = 52.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ChatStatusImage(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatStatusImage(chat: Chat) {
|
||||
val s = chat.serverInfo.networkStatus
|
||||
val descr = s.statusString
|
||||
if (s is Chat.NetworkStatus.Error) {
|
||||
Icon(
|
||||
Icons.Outlined.ErrorOutline,
|
||||
contentDescription = descr,
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(19.dp)
|
||||
)
|
||||
} else if (s !is Chat.NetworkStatus.Connected) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(15.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 1.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddLink
|
||||
import androidx.compose.material.icons.outlined.Link
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.PendingContactConnection
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
|
||||
@Composable
|
||||
fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
||||
Row {
|
||||
Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
|
||||
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) Icons.Outlined.AddLink else Icons.Outlined.Link)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1F)
|
||||
) {
|
||||
Text(
|
||||
contactConnection.displayName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Text(contactConnection.description, maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
val ts = getTimestampText(contactConnection.updatedAt)
|
||||
Column(
|
||||
Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
ts,
|
||||
color = HighOrLowlight,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatInfo
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
|
||||
@Composable
|
||||
fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
|
||||
Row {
|
||||
ChatInfoImage(contactRequest, size = 72.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1F)
|
||||
) {
|
||||
Text(
|
||||
contactRequest.chatViewName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
||||
Column(
|
||||
Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
ts,
|
||||
color = HighOrLowlight,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
|
||||
class AlertManager {
|
||||
var alertView = mutableStateOf<(@Composable () -> Unit)?>(null)
|
||||
var presentAlert = mutableStateOf<Boolean>(false)
|
||||
|
||||
fun showAlert(alert: @Composable () -> Unit) {
|
||||
Log.d(TAG, "AlertManager.showAlert")
|
||||
alertView.value = alert
|
||||
presentAlert.value = true
|
||||
}
|
||||
|
||||
fun hideAlert() {
|
||||
presentAlert.value = false
|
||||
alertView.value = null
|
||||
}
|
||||
|
||||
fun showAlertDialogButtons(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = buttons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialog(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertMsg(
|
||||
title: String, text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun showInView() {
|
||||
if (presentAlert.value) alertView.value?.invoke()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = AlertManager()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.SupervisedUserCircle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatInfo
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) {
|
||||
val icon =
|
||||
if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
|
||||
else Icons.Filled.AccountCircle
|
||||
ProfileImage(size, chatInfo.image, icon)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileImage(
|
||||
size: Dp,
|
||||
image: String? = null,
|
||||
icon: ImageVector = Icons.Filled.AccountCircle,
|
||||
color: Color = MaterialTheme.colors.secondary
|
||||
) {
|
||||
Box(Modifier.size(size)) {
|
||||
if (image == null) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
||||
tint = color,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
stringResource(R.string.image_descr_profile_image),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatInfoImage() {
|
||||
SimpleXTheme {
|
||||
ChatInfoImage(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
size = 55.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.newchat.ActionButton
|
||||
|
||||
sealed class AttachmentOption {
|
||||
object TakePhoto: AttachmentOption()
|
||||
object PickImage: AttachmentOption()
|
||||
object PickFile: AttachmentOption()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseAttachmentView(
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
hide: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.onFocusChanged { focusState ->
|
||||
if (!focusState.hasFocus) hide()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
||||
attachmentOption.value = AttachmentOption.TakePhoto
|
||||
hide()
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
||||
attachmentOption.value = AttachmentOption.PickImage
|
||||
hide()
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
||||
attachmentOption.value = AttachmentOption.PickFile
|
||||
hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun CloseSheetBar(close: () -> Unit) {
|
||||
Row (
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = close) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
stringResource(R.string.icon_descr_close_button),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewCloseSheetBar() {
|
||||
SimpleXTheme {
|
||||
CloseSheetBar(close = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.newchat.ActionButton
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
|
||||
fun cropToSquare(image: Bitmap): Bitmap {
|
||||
var xOffset = 0
|
||||
var yOffset = 0
|
||||
val side = min(image.height, image.width)
|
||||
if (image.height < image.width) {
|
||||
xOffset = (image.width - side) / 2
|
||||
} else {
|
||||
yOffset = (image.height - side) / 2
|
||||
}
|
||||
return Bitmap.createBitmap(image, xOffset, yOffset, side, side)
|
||||
}
|
||||
|
||||
fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
while (str.length > maxDataSize) {
|
||||
val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble())
|
||||
val clippedRatio = min(ratio, 2.0)
|
||||
val width = (img.width.toDouble() / clippedRatio).toInt()
|
||||
val height = img.height * width / img.width
|
||||
img = Bitmap.createScaledBitmap(img, width, height, true)
|
||||
str = compressImageStr(img)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
private fun compressImageStr(bitmap: Bitmap): String {
|
||||
return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream {
|
||||
var img = image
|
||||
var stream = compressImageData(img)
|
||||
while (stream.size() > maxDataSize) {
|
||||
val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
|
||||
val clippedRatio = min(ratio, 2.0)
|
||||
val width = (img.width.toDouble() / clippedRatio).toInt()
|
||||
val height = img.height * width / img.width
|
||||
img = Bitmap.createScaledBitmap(img, width, height, true)
|
||||
stream = compressImageData(img)
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
|
||||
val stream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream)
|
||||
return stream
|
||||
}
|
||||
|
||||
fun base64ToBitmap(base64ImageString: String): Bitmap {
|
||||
val imageString = base64ImageString
|
||||
.removePrefix("data:image/png;base64,")
|
||||
.removePrefix("data:image/jpg;base64,")
|
||||
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
|
||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
}
|
||||
|
||||
class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
||||
private var uri: Uri? = null
|
||||
private var tmpFile: File? = null
|
||||
lateinit var externalContext: Context
|
||||
|
||||
@CallSuper
|
||||
override fun createIntent(context: Context, input: Void?): Intent {
|
||||
externalContext = context
|
||||
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
|
||||
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
||||
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
|
||||
}
|
||||
|
||||
override fun getSynchronousResult(
|
||||
context: Context,
|
||||
input: Void?
|
||||
): SynchronousResult<Bitmap?>? = null
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
|
||||
return if (resultCode == Activity.RESULT_OK && uri != null) {
|
||||
val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
tmpFile?.delete()
|
||||
bitmap
|
||||
} else {
|
||||
Log.e(TAG, "Getting image from camera cancelled or failed.")
|
||||
tmpFile?.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
//class GetGalleryContent: ActivityResultContracts.GetContent() {
|
||||
// override fun createIntent(context: Context, input: String): Intent {
|
||||
// super.createIntent(context, input)
|
||||
// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
||||
// }
|
||||
//}
|
||||
//@Composable
|
||||
//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
|
||||
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
|
||||
@Composable
|
||||
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
|
||||
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
|
||||
|
||||
@Composable
|
||||
fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher<String, Boolean> =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb)
|
||||
|
||||
@Composable
|
||||
fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
|
||||
|
||||
@Composable
|
||||
fun GetImageBottomSheet(
|
||||
imageBitmap: MutableState<Bitmap?>,
|
||||
onImageChange: (Bitmap) -> Unit,
|
||||
hideBottomSheet: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
imageBitmap.value = bitmap
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
|
||||
if (bitmap != null) {
|
||||
imageBitmap.value = bitmap
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
cameraLauncher.launch(null)
|
||||
hideBottomSheet()
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.onFocusChanged { focusState ->
|
||||
if (!focusState.hasFocus) hideBottomSheet()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||
cameraLauncher.launch(null)
|
||||
hideBottomSheet()
|
||||
}
|
||||
else -> {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
||||
galleryLauncher.launch("image/*")
|
||||
hideBottomSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.LinkPreview
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
private const val OG_SELECT_QUERY = "meta[property^=og:]"
|
||||
|
||||
suspend fun getLinkPreview(url: String): LinkPreview? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = Jsoup.connect(url)
|
||||
.ignoreContentType(true)
|
||||
.timeout(10000)
|
||||
.followRedirects(true)
|
||||
.execute()
|
||||
val doc = response.parse()
|
||||
val ogTags = doc.select(OG_SELECT_QUERY)
|
||||
val imageUri = ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content")
|
||||
if (imageUri != null) {
|
||||
try {
|
||||
val stream = java.net.URL(imageUri).openStream()
|
||||
val image = resizeImageToStrSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000)
|
||||
// TODO add once supported in iOS
|
||||
// val description = ogTags.firstOrNull {
|
||||
// it.attr("property") == "og:description"
|
||||
// }?.attr("content") ?: ""
|
||||
val title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content")
|
||||
if (title != null) {
|
||||
return@withContext LinkPreview(url, title, description = "", image)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (linkPreview == null) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().weight(1f).height(60.dp).padding(start = 16.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
stringResource(R.string.image_descr_link_preview),
|
||||
modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp)
|
||||
)
|
||||
Column(Modifier.fillMaxWidth().weight(1F)) {
|
||||
Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_link_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemLinkView(linkPreview: LinkPreview) {
|
||||
Column {
|
||||
Image(
|
||||
base64ToBitmap(linkPreview.image).asImageBitmap(),
|
||||
stringResource(R.string.image_descr_link_preview),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
|
||||
Text(linkPreview.title, maxLines = 3, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp, modifier = Modifier.padding(bottom = 4.dp))
|
||||
if (linkPreview.description != "") {
|
||||
Text(linkPreview.description, maxLines = 12, overflow = TextOverflow.Ellipsis, fontSize = 14.sp, lineHeight = 20.sp)
|
||||
}
|
||||
Text(linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "ChatItemLinkView (Dark Mode)"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewChatItemLinkView() {
|
||||
SimpleXTheme {
|
||||
ChatItemLinkView(LinkPreview.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "ComposeLinkView (Dark Mode)"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewComposeLinkView() {
|
||||
SimpleXTheme {
|
||||
ComposeLinkView(LinkPreview.sampleData) { -> }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewComposeLinkViewLoading() {
|
||||
SimpleXTheme {
|
||||
ComposeLinkView(null) { -> }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.*
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
|
||||
sealed class LAResult {
|
||||
object Success: LAResult()
|
||||
class Error(val errString: CharSequence): LAResult()
|
||||
object Failed: LAResult()
|
||||
object Unavailable: LAResult()
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
activity: FragmentActivity,
|
||||
completed: (LAResult) -> Unit
|
||||
) {
|
||||
when {
|
||||
SDK_INT in 28..29 ->
|
||||
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
SDK_INT > 29 ->
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
else ->
|
||||
completed(LAResult.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateWithBiometricManager(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
activity: FragmentActivity,
|
||||
completed: (LAResult) -> Unit,
|
||||
authenticators: Int
|
||||
) {
|
||||
val biometricManager = BiometricManager.from(activity)
|
||||
when (biometricManager.canAuthenticate(authenticators)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> {
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
activity,
|
||||
executor,
|
||||
object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(
|
||||
errorCode: Int,
|
||||
errString: CharSequence
|
||||
) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
completed(LAResult.Error(errString))
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult
|
||||
) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
completed(LAResult.Success)
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
completed(LAResult.Failed)
|
||||
}
|
||||
}
|
||||
)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(promptTitle)
|
||||
.setSubtitle(promptSubtitle)
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
else -> {
|
||||
completed(LAResult.Unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_simplex_lock_turned_on),
|
||||
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
|
||||
)
|
||||
|
||||
fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText(
|
||||
context,
|
||||
if (errString.isNotEmpty()) String.format(generalGetString(R.string.auth_error_w_desc), errString) else generalGetString(R.string.auth_error),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
fun laFailedToast(context: Context) = Toast.makeText(
|
||||
context,
|
||||
generalGetString(R.string.auth_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_unavailable),
|
||||
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
|
||||
)
|
||||
|
||||
fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_unavailable),
|
||||
generalGetString(R.string.auth_device_authentication_is_disabled_turning_off)
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.TAG
|
||||
|
||||
@Composable
|
||||
fun ModalView(
|
||||
close: () -> Unit,
|
||||
background: Color = MaterialTheme.colors.background,
|
||||
modifier: Modifier = Modifier.padding(horizontal = 16.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.background(background)) {
|
||||
CloseSheetBar(close)
|
||||
Box(modifier) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ModalManager {
|
||||
private val modalViews = arrayListOf<(@Composable (close: () -> Unit) -> Unit)?>()
|
||||
private val modalCount = mutableStateOf(0)
|
||||
|
||||
fun showModal(content: @Composable () -> Unit) {
|
||||
showCustomModal { close -> ModalView(close, content = content) }
|
||||
}
|
||||
|
||||
fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
|
||||
Log.d(TAG, "ModalManager.showModal")
|
||||
modalViews.add(modal)
|
||||
modalCount.value = modalViews.count()
|
||||
}
|
||||
|
||||
fun closeModal() {
|
||||
if (modalViews.isNotEmpty()) {
|
||||
modalViews.removeAt(modalViews.count() - 1)
|
||||
}
|
||||
modalCount.value = modalViews.count()
|
||||
}
|
||||
|
||||
fun closeModals() {
|
||||
while (modalViews.isNotEmpty()) closeModal()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun showInView() {
|
||||
if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = ModalManager()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.layout
|
||||
|
||||
fun Modifier.badgeLayout() =
|
||||
layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
|
||||
// based on the expectation of only one line of text
|
||||
val minPadding = placeable.height / 4
|
||||
|
||||
val width = maxOf(placeable.width + minPadding, placeable.height)
|
||||
layout(width, placeable.height) {
|
||||
placeable.place((width - placeable.width) / 2, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIFile
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
|
||||
fun shareText(cxt: Context, text: String) {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
type = "text/plain"
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
cxt.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
fun copyText(cxt: Context, text: String) {
|
||||
val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java)
|
||||
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResultLauncher<String, Uri?> =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { destination ->
|
||||
destination?.let {
|
||||
val filePath = getLoadedFilePath(cxt, ciFile)
|
||||
if (filePath != null) {
|
||||
val contentResolver = cxt.contentResolver
|
||||
contentResolver.openOutputStream(destination)?.let { stream ->
|
||||
val outputStream = BufferedOutputStream(stream)
|
||||
val file = File(filePath)
|
||||
outputStream.write(file.readBytes())
|
||||
outputStream.close()
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
fun saveImage(cxt: Context, ciFile: CIFile?) {
|
||||
val filePath = getLoadedFilePath(cxt, ciFile)
|
||||
val fileName = ciFile?.fileName
|
||||
if (filePath != null && fileName != null) {
|
||||
val values = ContentValues()
|
||||
values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
values.put(MediaStore.MediaColumns.TITLE, fileName)
|
||||
val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
uri?.let {
|
||||
cxt.contentResolver.openOutputStream(uri)?.let { stream ->
|
||||
val outputStream = BufferedOutputStream(stream)
|
||||
val file = File(filePath)
|
||||
outputStream.write(file.readBytes())
|
||||
outputStream.close()
|
||||
Toast.makeText(cxt, generalGetString(R.string.image_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SimpleButton(text: String, icon: ImageVector,
|
||||
color: Color = MaterialTheme.colors.primary,
|
||||
click: () -> Unit) {
|
||||
SimpleButtonFrame(click) {
|
||||
Icon(
|
||||
icon, text, tint = color,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(text, style = MaterialTheme.typography.caption, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleButtonFrame(click: () -> Unit, content: @Composable () -> Unit) {
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable { click() }
|
||||
.padding(8.dp)
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCloseSheetBar() {
|
||||
SimpleXTheme {
|
||||
SimpleButton(text = "Share", icon = Icons.Outlined.Share, click = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
|
||||
@Composable
|
||||
fun TextEditor(modifier: Modifier, text: MutableState<String>) {
|
||||
BasicTextField(
|
||||
value = text.value,
|
||||
onValueChange = { text.value = it },
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
modifier = modifier,
|
||||
cursorBrush = SolidColor(HighOrLowlight),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
) {
|
||||
Row(
|
||||
Modifier.background(MaterialTheme.colors.background),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 5.dp, horizontal = 7.dp)
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.*
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.FileUtils
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.Spanned
|
||||
import android.text.SpannedString
|
||||
import android.text.style.*
|
||||
import android.util.Log
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.CIFile
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
|
||||
fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action)
|
||||
|
||||
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
|
||||
scope.launch { withContext(Dispatchers.Main, action) }
|
||||
|
||||
enum class KeyboardState {
|
||||
Opened, Closed
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getKeyboardState(): State<KeyboardState> {
|
||||
val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
|
||||
KeyboardState.Opened
|
||||
} else {
|
||||
KeyboardState.Closed
|
||||
}
|
||||
}
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
|
||||
|
||||
onDispose {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
|
||||
}
|
||||
}
|
||||
|
||||
return keyboardState
|
||||
}
|
||||
|
||||
// Resource to annotated string from
|
||||
// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources
|
||||
fun generalGetString(id: Int): String {
|
||||
// prefer stringResource in Composable items to retain preview abilities
|
||||
return SimplexApp.context.getString(id)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun resources(): Resources {
|
||||
LocalConfiguration.current
|
||||
return LocalContext.current.resources
|
||||
}
|
||||
|
||||
fun Spanned.toHtmlWithoutParagraphs(): String {
|
||||
return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)
|
||||
.substringAfter("<p dir=\"ltr\">").substringBeforeLast("</p>")
|
||||
}
|
||||
|
||||
fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence {
|
||||
val escapedArgs = args.map {
|
||||
if (it is Spanned) it.toHtmlWithoutParagraphs() else it
|
||||
}.toTypedArray()
|
||||
val resource = SpannedString(getText(id))
|
||||
val htmlResource = resource.toHtmlWithoutParagraphs()
|
||||
val formattedHtml = String.format(htmlResource, *escapedArgs)
|
||||
return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
|
||||
val resources = resources()
|
||||
val density = LocalDensity.current
|
||||
return remember(id) {
|
||||
val text = resources.getText(id)
|
||||
spannableStringToAnnotatedString(text, density)
|
||||
}
|
||||
}
|
||||
|
||||
private fun spannableStringToAnnotatedString(
|
||||
text: CharSequence,
|
||||
density: Density,
|
||||
): AnnotatedString {
|
||||
return if (text is Spanned) {
|
||||
with(density) {
|
||||
buildAnnotatedString {
|
||||
append((text.toString()))
|
||||
text.getSpans(0, text.length, Any::class.java).forEach {
|
||||
val start = text.getSpanStart(it)
|
||||
val end = text.getSpanEnd(it)
|
||||
when (it) {
|
||||
is StyleSpan -> when (it.style) {
|
||||
Typeface.NORMAL -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal,
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
Typeface.BOLD -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Normal
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
Typeface.ITALIC -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Italic
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
Typeface.BOLD_ITALIC -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
}
|
||||
is TypefaceSpan -> addStyle(
|
||||
SpanStyle(
|
||||
fontFamily = when (it.family) {
|
||||
FontFamily.SansSerif.name -> FontFamily.SansSerif
|
||||
FontFamily.Serif.name -> FontFamily.Serif
|
||||
FontFamily.Monospace.name -> FontFamily.Monospace
|
||||
FontFamily.Cursive.name -> FontFamily.Cursive
|
||||
else -> FontFamily.Default
|
||||
}
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is AbsoluteSizeSpan -> addStyle(
|
||||
SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is RelativeSizeSpan -> addStyle(
|
||||
SpanStyle(fontSize = it.sizeChange.em),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is StrikethroughSpan -> addStyle(
|
||||
SpanStyle(textDecoration = TextDecoration.LineThrough),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is UnderlineSpan -> addStyle(
|
||||
SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is SuperscriptSpan -> addStyle(
|
||||
SpanStyle(baselineShift = BaselineShift.Superscript),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is SubscriptSpan -> addStyle(
|
||||
SpanStyle(baselineShift = BaselineShift.Subscript),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is ForegroundColorSpan -> addStyle(
|
||||
SpanStyle(color = Color(it.foregroundColor)),
|
||||
start,
|
||||
end
|
||||
)
|
||||
else -> addStyle(SpanStyle(color = Color.White), start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AnnotatedString(text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
// maximum image file size to be auto-accepted
|
||||
const val MAX_IMAGE_SIZE: Long = 236700
|
||||
const val MAX_FILE_SIZE: Long = 8000000
|
||||
|
||||
fun getFilesDirectory(context: Context): String {
|
||||
return context.filesDir.toString()
|
||||
}
|
||||
|
||||
fun getAppFilesDirectory(context: Context): String {
|
||||
return "${getFilesDirectory(context)}/app_files"
|
||||
}
|
||||
|
||||
fun getAppFilePath(context: Context, fileName: String): String {
|
||||
return "${getAppFilesDirectory(context)}/$fileName"
|
||||
}
|
||||
|
||||
fun getLoadedFilePath(context: Context, file: CIFile?): String? {
|
||||
return if (file?.filePath != null && file.loaded) {
|
||||
val filePath = getAppFilePath(context, file.filePath)
|
||||
if (File(filePath).exists()) filePath else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
|
||||
fun getLoadedImage(context: Context, file: CIFile?): Bitmap? {
|
||||
val filePath = getLoadedFilePath(context, file)
|
||||
return if (filePath != null) {
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r")
|
||||
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
|
||||
val image = BitmapFactory.decodeFileDescriptor(fileDescriptor)
|
||||
parcelFileDescriptor?.close()
|
||||
image
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileName(context: Context, uri: Uri): String? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileSize(context: Context, uri: Uri): Long? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImage(context: Context, image: Bitmap): String? {
|
||||
return try {
|
||||
val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.jpg")
|
||||
val file = File(getAppFilePath(context, fileToSave))
|
||||
val output = FileOutputStream(file)
|
||||
dataResized.writeTo(output)
|
||||
output.flush()
|
||||
output.close()
|
||||
fileToSave
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveFileFromUri(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val fileToSave = getFileName(context, uri)
|
||||
if (inputStream != null && fileToSave != null) {
|
||||
val destFileName = uniqueCombine(context, fileToSave)
|
||||
val destFile = File(getAppFilePath(context, destFileName))
|
||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
destFileName
|
||||
} else {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun uniqueCombine(context: Context, fileName: String): String {
|
||||
fun tryCombine(fileName: String, n: Int): String {
|
||||
val name = File(fileName).nameWithoutExtension
|
||||
val ext = File(fileName).extension
|
||||
val suffix = if (n == 0) "" else "_$n"
|
||||
val f = "$name$suffix.$ext"
|
||||
return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f
|
||||
}
|
||||
return tryCombine(fileName, 0)
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
if (bytes == 0.toLong()) {
|
||||
return "0 bytes"
|
||||
}
|
||||
val bytesDouble = bytes.toDouble()
|
||||
val k = 1000.toDouble()
|
||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||
val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
|
||||
val size = bytesDouble / k.pow(i)
|
||||
val unit = units[i.toInt()]
|
||||
|
||||
return if (i <= 1) {
|
||||
String.format("%.0f %s", size, unit)
|
||||
} else {
|
||||
String.format("%.2f %s", size, unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFile(context: Context, fileName: String): Boolean {
|
||||
val file = File(getAppFilePath(context, fileName))
|
||||
val fileDeleted = file.delete()
|
||||
if (!fileDeleted) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt removeFile error")
|
||||
}
|
||||
return fileDeleted
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.shareText
|
||||
|
||||
@Composable
|
||||
fun AddContactView(chatModel: ChatModel) {
|
||||
val connReq = chatModel.connReqInvitation
|
||||
if (connReq != null) {
|
||||
val cxt = LocalContext.current
|
||||
AddContactLayout(
|
||||
connReq = connReq,
|
||||
share = { shareText(cxt, connReq) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddContactLayout(connReq: String, share: () -> Unit) {
|
||||
BoxWithConstraints {
|
||||
val screenHeight = maxHeight
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.add_contact),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
|
||||
style = MaterialTheme.typography.h3,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
QRCode(
|
||||
connReq, Modifier
|
||||
.weight(1f, fill = false)
|
||||
.aspectRatio(1f)
|
||||
.padding(vertical = 3.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 22.sp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp)
|
||||
)
|
||||
SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewAddContactView() {
|
||||
SimpleXTheme {
|
||||
AddContactLayout(
|
||||
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
|
||||
share = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chatlist.ScaffoldController
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
NewChatSheetLayout(
|
||||
addContact = {
|
||||
withApi {
|
||||
// show spinner
|
||||
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
|
||||
// hide spinner
|
||||
if (chatModel.connReqInvitation != null) {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showModal { AddContactView(chatModel) }
|
||||
}
|
||||
}
|
||||
},
|
||||
scanCode = {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
},
|
||||
pasteLink = {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.add_contact_to_start_new_chat),
|
||||
modifier = Modifier.padding(horizontal = 8.dp).padding(top = 32.dp)
|
||||
)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(top = 24.dp, bottom = 40.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.fillMaxWidth()) {
|
||||
ActionButton(
|
||||
stringResource(R.string.create_one_time_link),
|
||||
stringResource(R.string.to_share_with_your_contact),
|
||||
Icons.Outlined.AddLink,
|
||||
click = addContact
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.fillMaxWidth()) {
|
||||
ActionButton(
|
||||
stringResource(R.string.paste_received_link),
|
||||
stringResource(R.string.paste_received_link_from_clipboard),
|
||||
Icons.Outlined.Article,
|
||||
click = pasteLink
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.fillMaxWidth()) {
|
||||
ActionButton(
|
||||
stringResource(R.string.scan_QR_code),
|
||||
stringResource(R.string.in_person_or_in_video_call__bracketed),
|
||||
Icons.Outlined.QrCode,
|
||||
click = scanCode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
|
||||
click: () -> Unit = {}) {
|
||||
Surface(shape = RoundedCornerShape(18.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
.clickable(onClick = click)
|
||||
.padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
Icon(icon, text,
|
||||
tint = tint,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.padding(bottom = 8.dp))
|
||||
if (text != null) {
|
||||
Text(
|
||||
text,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = tint,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
if (comment != null) {
|
||||
Text(
|
||||
comment,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewNewChatSheet() {
|
||||
SimpleXTheme {
|
||||
NewChatSheetLayout(
|
||||
addContact = {},
|
||||
scanCode = {},
|
||||
pasteLink = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val connectionLink = remember { mutableStateOf("")}
|
||||
val context = LocalContext.current
|
||||
val clipboard = getSystemService(context, ClipboardManager::class.java)
|
||||
BackHandler(onBack = close)
|
||||
PasteToConnectLayout(
|
||||
connectionLink = connectionLink,
|
||||
pasteFromClipboard = {
|
||||
connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String
|
||||
},
|
||||
connectViaLink = { connReqUri ->
|
||||
try {
|
||||
val uri = Uri.parse(connReqUri)
|
||||
withUriAction(uri) { action ->
|
||||
connectViaUri(chatModel, action, uri)
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.invalid_connection_link),
|
||||
text = generalGetString(R.string.this_string_is_not_a_connection_link)
|
||||
)
|
||||
}
|
||||
close()
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PasteToConnectLayout(
|
||||
connectionLink: MutableState<String>,
|
||||
pasteFromClipboard: () -> Unit,
|
||||
connectViaLink: (String) -> Unit,
|
||||
close: () -> Unit
|
||||
) {
|
||||
ModalView(close) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.connect_via_link),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
Text(stringResource(R.string.paste_connection_link_below_to_connect))
|
||||
Text(stringResource(R.string.profile_will_be_sent_to_contact_sending_link))
|
||||
|
||||
Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) {
|
||||
TextEditor(Modifier.height(180.dp), text = connectionLink)
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
if (connectionLink.value == "") {
|
||||
SimpleButton(text = "Paste", icon = Icons.Outlined.ContentPaste) {
|
||||
pasteFromClipboard()
|
||||
}
|
||||
} else {
|
||||
SimpleButton(text = "Clear", icon = Icons.Outlined.Clear) {
|
||||
connectionLink.value = ""
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f).fillMaxWidth())
|
||||
SimpleButton(text = "Connect", icon = Icons.Outlined.Link) {
|
||||
connectViaLink(connectionLink.value)
|
||||
}
|
||||
}
|
||||
|
||||
Text(annotatedStringResource(R.string.you_can_also_connect_by_clicking_the_link))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewPasteToConnectTextbox() {
|
||||
SimpleXTheme {
|
||||
PasteToConnectLayout(
|
||||
connectionLink = remember { mutableStateOf("") },
|
||||
pasteFromClipboard = {},
|
||||
connectViaLink = { link ->
|
||||
try {
|
||||
println(link)
|
||||
// withApi { chatModel.controller.apiConnect(link) }
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
},
|
||||
close = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import boofcv.alg.fiducial.qrcode.QrCodeEncoder
|
||||
import boofcv.alg.fiducial.qrcode.QrCodeGeneratorImage
|
||||
import boofcv.android.ConvertBitmap
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun QRCode(connReq: String, modifier: Modifier = Modifier) {
|
||||
Image(
|
||||
bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.image_descr_qr_code),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
fun qrCodeBitmap(content: String, size: Int): Bitmap {
|
||||
val qrCode = QrCodeEncoder().addAutomatic(content).fixate()
|
||||
val renderer = QrCodeGeneratorImage(5)
|
||||
renderer.render(qrCode)
|
||||
return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewQRCode() {
|
||||
SimpleXTheme {
|
||||
QRCode(connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import boofcv.abst.fiducial.QrCodeDetector
|
||||
import boofcv.alg.color.ColorFormat
|
||||
import boofcv.android.ConvertCameraImage
|
||||
import boofcv.factory.fiducial.FactoryFiducial
|
||||
import boofcv.struct.image.GrayU8
|
||||
import chat.simplex.app.TAG
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import java.util.concurrent.*
|
||||
|
||||
// Adapted from learntodroid - https://gist.github.com/learntodroid/8f839be0b29d0378f843af70607bd7f5
|
||||
|
||||
@Composable
|
||||
fun QRCodeScanner(onBarcode: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var preview by remember { mutableStateOf<Preview?>(null) }
|
||||
var lastAnalyzedTimeStamp = 0L
|
||||
var contactLink = ""
|
||||
|
||||
AndroidView(
|
||||
factory = { AndroidViewContext ->
|
||||
PreviewView(AndroidViewContext).apply {
|
||||
this.scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
}
|
||||
) { previewView ->
|
||||
val cameraSelector: CameraSelector = CameraSelector.Builder()
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||
.build()
|
||||
val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> =
|
||||
ProcessCameraProvider.getInstance(context)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
|
||||
val detector: QrCodeDetector<GrayU8> = FactoryFiducial.qrcode(null, GrayU8::class.java)
|
||||
fun getQR(imageProxy: ImageProxy) {
|
||||
val currentTimeStamp = System.currentTimeMillis()
|
||||
if (currentTimeStamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) {
|
||||
detector.process(imageProxyToGrayU8(imageProxy))
|
||||
val found = detector.detections
|
||||
val qr = found.firstOrNull()
|
||||
if (qr != null) {
|
||||
if (qr.message != contactLink) {
|
||||
// Make sure link is new and not a repeat
|
||||
contactLink = qr.message
|
||||
onBarcode(contactLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
imageProxy.close()
|
||||
}
|
||||
val imageAnalyzer = ImageAnalysis.Analyzer { proxy -> getQR(proxy) }
|
||||
val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.setImageQueueDepth(1)
|
||||
.build()
|
||||
.also { it.setAnalyzer(cameraExecutor, imageAnalyzer) }
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "CameraPreview: ${e.localizedMessage}")
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun imageProxyToGrayU8(img: ImageProxy) : GrayU8? {
|
||||
val image = img.image
|
||||
if (image != null) {
|
||||
val outImg = GrayU8()
|
||||
ConvertCameraImage.imageToBoof(image, ColorFormat.GRAY, outImg, null)
|
||||
return outImg
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
ConnectContactLayout(
|
||||
qrCodeScanner = {
|
||||
QRCodeScanner { connReqUri ->
|
||||
try {
|
||||
val uri = Uri.parse(connReqUri)
|
||||
withUriAction(uri) { action ->
|
||||
connectViaUri(chatModel, action, uri)
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.invalid_QR_code),
|
||||
text = generalGetString(R.string.this_QR_code_is_not_a_link)
|
||||
)
|
||||
}
|
||||
close()
|
||||
}
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
|
||||
fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
|
||||
val action = uri.path?.drop(1)
|
||||
if (action == "contact" || action == "invitation") {
|
||||
withApi { run(action) }
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.invalid_contact_link),
|
||||
text = generalGetString(R.string.this_link_is_not_a_valid_connection_link)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
|
||||
val r = chatModel.controller.apiConnect(uri.toString())
|
||||
if (r) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.connection_request_sent),
|
||||
text =
|
||||
if (action == "contact") generalGetString(R.string.you_will_be_connected_when_your_connection_request_is_accepted)
|
||||
else generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
|
||||
ModalView(close) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
generalGetString(R.string.scan_QR_code),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
)
|
||||
Text(
|
||||
generalGetString(R.string.your_chat_profile_will_be_sent_to_your_contact),
|
||||
style = MaterialTheme.typography.h3,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
) { qrCodeScanner() }
|
||||
Text(
|
||||
annotatedStringResource(R.string.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewConnectContactLayout() {
|
||||
SimpleXTheme {
|
||||
ConnectContactLayout(
|
||||
qrCodeScanner = { Surface {} },
|
||||
close = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.annotatedStringResource
|
||||
|
||||
@Composable
|
||||
fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = null) {
|
||||
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
|
||||
Text(stringResource(R.string.how_simplex_works), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = 8.dp))
|
||||
ReadableText(R.string.many_people_asked_how_can_it_deliver)
|
||||
ReadableText(R.string.to_protect_privacy_simplex_has_ids_for_queues)
|
||||
ReadableText(R.string.you_control_servers_to_receive_your_contacts_to_send)
|
||||
ReadableText(R.string.only_client_devices_store_contacts_groups_e2e_encrypted_messages)
|
||||
if (onboardingStage == null) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Text(
|
||||
annotatedStringResource(R.string.read_more_in_github_with_link),
|
||||
modifier = Modifier.padding(bottom = 12.dp).clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat#readme") },
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
} else {
|
||||
ReadableText(R.string.read_more_in_github)
|
||||
}
|
||||
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
|
||||
if (onboardingStage != null) {
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.shared.closeModal() })
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReadableText(@StringRes stringResId: Int) {
|
||||
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(bottom = 12.dp), lineHeight = 22.sp)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewHowItWorks() {
|
||||
SimpleXTheme {
|
||||
HowItWorks(user = null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.Manifest
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
fun MakeConnection(chatModel: ChatModel) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
MakeConnectionLayout(
|
||||
chatModel.currentUser.value,
|
||||
createLink = {
|
||||
withApi {
|
||||
// show spinner
|
||||
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
|
||||
// hide spinner
|
||||
if (chatModel.connReqInvitation != null) {
|
||||
ModalManager.shared.showModal { AddContactView(chatModel) }
|
||||
}
|
||||
}
|
||||
},
|
||||
pasteLink = {
|
||||
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
|
||||
},
|
||||
scanCode = {
|
||||
ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
},
|
||||
about = {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MakeConnectionLayout(
|
||||
user: User?,
|
||||
createLink: () -> Unit,
|
||||
pasteLink: () -> Unit,
|
||||
scanCode: () -> Unit,
|
||||
about: () -> Unit
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = MaterialTheme.colors.background)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
if (user == null) stringResource(R.string.make_private_connection)
|
||||
else String.format(stringResource(R.string.personal_welcome), user.profile.displayName),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
annotatedStringResource(R.string.to_make_your_first_private_connection_choose),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
ActionRow(
|
||||
Icons.Outlined.AddLink,
|
||||
R.string.create_1_time_link_qr_code,
|
||||
R.string.it_s_secure_to_share__only_one_contact_can_use_it,
|
||||
createLink
|
||||
)
|
||||
ActionRow(
|
||||
Icons.Outlined.Article,
|
||||
R.string.paste_the_link_you_received,
|
||||
R.string.or_open_the_link_in_the_browser_and_tap_open_in_mobile,
|
||||
pasteLink
|
||||
)
|
||||
ActionRow(
|
||||
Icons.Outlined.QrCode,
|
||||
R.string.scan_contact_s_qr_code,
|
||||
R.string.in_person_or_via_a_video_call__the_most_secure_way_to_connect,
|
||||
scanCode
|
||||
)
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.or))
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
ActionRow(
|
||||
Icons.Outlined.Tag,
|
||||
R.string.connect_with_the_developers,
|
||||
R.string.to_ask_any_questions_and_to_receive_simplex_chat_updates,
|
||||
{ uriHandler.openUri(simplexTeamUri) }
|
||||
)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
SimpleButton(
|
||||
text = stringResource(R.string.about_simplex),
|
||||
icon = Icons.Outlined.ArrowBackIosNew,
|
||||
click = about
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionRow(icon: ImageVector, @StringRes titleId: Int, @StringRes textId: Int, action: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.clickable { action() }
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Icon(icon, stringResource(titleId), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(end = 10.dp).size(40.dp))
|
||||
Column {
|
||||
Text(stringResource(titleId), color = MaterialTheme.colors.primary)
|
||||
Text(annotatedStringResource(textId))
|
||||
}
|
||||
}
|
||||
// Button(action: action, label: {
|
||||
// HStack(alignment: .top, spacing: 20) {
|
||||
// Image(systemName: icon)
|
||||
// .resizable()
|
||||
// .scaledToFit()
|
||||
// .frame(width: 30, height: 30)
|
||||
// .padding(.leading, 4)
|
||||
// .padding(.top, 6)
|
||||
// VStack(alignment: .leading) {
|
||||
// Group {
|
||||
// Text(title).font(.headline)
|
||||
// Text(text).foregroundColor(.primary)
|
||||
// }
|
||||
// .multilineTextAlignment(.leading)
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// .padding(.bottom)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewMakeConnection() {
|
||||
SimpleXTheme {
|
||||
MakeConnectionLayout(
|
||||
user = User.sampleData,
|
||||
createLink = {},
|
||||
pasteLink = {},
|
||||
scanCode = {},
|
||||
about = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.CreateProfilePanel
|
||||
import chat.simplex.app.views.helpers.getKeyboardState
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class OnboardingStage {
|
||||
Step1_SimpleXInfo,
|
||||
Step2_CreateProfile,
|
||||
OnboardingComplete
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProfile(chatModel: ChatModel) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = MaterialTheme.colors.background)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
CreateProfilePanel(chatModel)
|
||||
if (savedKeyboardState != keyboardState) {
|
||||
LaunchedEffect(keyboardState) {
|
||||
scope.launch {
|
||||
savedKeyboardState = keyboardState
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
|
||||
@Composable
|
||||
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
|
||||
SimpleXInfoLayout(
|
||||
user = chatModel.currentUser.value,
|
||||
onboardingStage = if (onboarding) chatModel.onboardingStage else null,
|
||||
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleXInfoLayout(
|
||||
user: User?,
|
||||
onboardingStage: MutableState<OnboardingStage?>?,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
) {
|
||||
Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
|
||||
SimpleXLogo()
|
||||
|
||||
Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp))
|
||||
|
||||
InfoRow("🎭", R.string.privacy_redefined, R.string.first_platform_without_user_ids)
|
||||
InfoRow("📭", R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share)
|
||||
InfoRow("🤝", R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers)
|
||||
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f))
|
||||
|
||||
if (onboardingStage != null) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
OnboardingActionButton(user, onboardingStage)
|
||||
}
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f))
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
SimpleButton(text = stringResource(R.string.how_it_works), icon = Icons.Outlined.Info,
|
||||
click = showModal { HowItWorks(user, onboardingStage) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleXLogo() {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo),
|
||||
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
.fillMaxWidth(0.80f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) {
|
||||
Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) {
|
||||
Text(emoji, fontSize = 36.sp, modifier = Modifier
|
||||
.width(60.dp)
|
||||
.padding(end = 16.dp))
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Text(stringResource(titleId), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h3, lineHeight = 24.sp)
|
||||
Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
|
||||
if (user == null) {
|
||||
ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
|
||||
} else {
|
||||
ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
@StringRes labelId: Int,
|
||||
onboarding: OnboardingStage?,
|
||||
onboardingStage: MutableState<OnboardingStage?>,
|
||||
onclick: (() -> Unit)?
|
||||
) {
|
||||
SimpleButtonFrame(click = {
|
||||
onclick?.invoke()
|
||||
onboardingStage.value = onboarding
|
||||
}) {
|
||||
Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary)
|
||||
Icon(
|
||||
Icons.Outlined.ArrowForwardIos, "next stage", tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSimpleXInfo() {
|
||||
SimpleXTheme {
|
||||
SimpleXInfoLayout(
|
||||
user = null,
|
||||
onboardingStage = null,
|
||||
showModal = {{}}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
|
||||
@Composable
|
||||
fun CallSettingsView(m: ChatModel) {
|
||||
CallSettingsLayout(
|
||||
webrtcPolicyRelay = m.controller.appPrefs.webrtcPolicyRelay,
|
||||
callOnLockScreen = m.controller.appPrefs.callOnLockScreen
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallSettingsLayout(
|
||||
webrtcPolicyRelay: Preference<Boolean>,
|
||||
callOnLockScreen: Preference<CallOnLockScreen>,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
|
||||
Text(
|
||||
stringResource(R.string.your_calls),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
Box(Modifier.padding(start = 10.dp)) {
|
||||
SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay)
|
||||
}
|
||||
divider()
|
||||
|
||||
Column(Modifier.padding(start = 10.dp, top = 12.dp)) {
|
||||
Text(stringResource(R.string.call_on_lock_screen))
|
||||
Row {
|
||||
SharedPreferenceRadioButton(stringResource(R.string.no_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.DISABLE)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
SharedPreferenceRadioButton(stringResource(R.string.show_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.SHOW)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPreferenceToggle(
|
||||
text: String,
|
||||
preference: Preference<Boolean>,
|
||||
preferenceState: MutableState<Boolean>? = null
|
||||
) {
|
||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text, Modifier.padding(end = 24.dp))
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = prefState.value,
|
||||
onCheckedChange = {
|
||||
preference.set(it)
|
||||
prefState.value = it
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text)
|
||||
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)
|
||||
RadioButton(selected = prefState.value == value, colors = colors, onClick = {
|
||||
preference.set(value)
|
||||
prefState.value = value
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Videocam
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
|
||||
@Composable
|
||||
fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boolean>) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_experimental_features),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
SettingsSectionView("") {
|
||||
SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chatlist.ChatHelpView
|
||||
|
||||
@Composable
|
||||
fun HelpView(chatModel: ChatModel) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
HelpLayout(displayName = user.profile.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HelpLayout(displayName: String) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
){
|
||||
Text(
|
||||
String.format(stringResource(R.string.personal_welcome), displayName),
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
ChatHelpView()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewHelpView() {
|
||||
SimpleXTheme {
|
||||
HelpLayout(displayName = "Alice")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.Format
|
||||
import chat.simplex.app.model.FormatColor
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun MarkdownHelpView() {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.how_to_use_markdown),
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.you_can_use_markdown_to_format_messages__prompt),
|
||||
Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
val bold = stringResource(R.string.bold)
|
||||
val italic = stringResource(R.string.italic)
|
||||
val strikethrough = stringResource(R.string.strikethrough)
|
||||
val equation = stringResource(R.string.a_plus_b)
|
||||
val colored = stringResource(R.string.colored)
|
||||
val secret = stringResource(R.string.secret)
|
||||
|
||||
MdFormat("*$bold*", bold, Format.Bold())
|
||||
MdFormat("_${italic}_", italic, Format.Italic())
|
||||
MdFormat("~$strikethrough~", strikethrough, Format.StrikeThrough())
|
||||
MdFormat("`$equation`", equation, Format.Snippet())
|
||||
Row {
|
||||
MdSyntax("!1 $colored!")
|
||||
Text(buildAnnotatedString {
|
||||
withStyle(Format.Colored(FormatColor.red).style) { append(colored) }
|
||||
append(" (")
|
||||
appendColor(this, "1", FormatColor.red, ", ")
|
||||
appendColor(this, "2", FormatColor.green, ", ")
|
||||
appendColor(this, "3", FormatColor.blue, ", ")
|
||||
appendColor(this, "4", FormatColor.yellow, ", ")
|
||||
appendColor(this, "5", FormatColor.cyan, ", ")
|
||||
appendColor(this, "6", FormatColor.magenta, ")")
|
||||
})
|
||||
}
|
||||
Row {
|
||||
MdSyntax("#$secret#")
|
||||
SelectionContainer {
|
||||
Text(buildAnnotatedString {
|
||||
withStyle(Format.Secret().style) { append(secret) }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MdSyntax(markdown: String) {
|
||||
Text(markdown, Modifier
|
||||
.width(120.dp)
|
||||
.padding(bottom = 4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MdFormat(markdown: String, example: String, format: Format) {
|
||||
Row {
|
||||
MdSyntax(markdown)
|
||||
Text(buildAnnotatedString {
|
||||
withStyle(format.style) { append(example) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: String) {
|
||||
b.withStyle(Format.Colored(c).style) { append(s)}
|
||||
b.append(after)
|
||||
}
|
||||
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewMarkdownHelpView() {
|
||||
SimpleXTheme {
|
||||
MarkdownHelpView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material.icons.outlined.TravelExplore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
|
||||
@Composable
|
||||
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.your_privacy),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_device)) {
|
||||
ChatLockItem(chatModel.performLA, setPerformLA)
|
||||
}
|
||||
Spacer(Modifier.height(30.dp))
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_chats)) {
|
||||
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
|
||||
divider()
|
||||
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.OpenInNew
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun SMPServersView(chatModel: ChatModel) {
|
||||
val userSMPServers = chatModel.userSMPServers.value
|
||||
if (userSMPServers != null) {
|
||||
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
|
||||
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
|
||||
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
|
||||
fun saveSMPServers(smpServers: List<String>) {
|
||||
withApi {
|
||||
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
|
||||
if (r) {
|
||||
chatModel.userSMPServers.value = smpServers
|
||||
if (smpServers.isEmpty()) {
|
||||
isUserSMPServers = false
|
||||
editSMPServers = true
|
||||
} else {
|
||||
editSMPServers = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = isUserSMPServers,
|
||||
editSMPServers = editSMPServers,
|
||||
userSMPServersStr = userSMPServersStr,
|
||||
isUserSMPServersOnOff = { switch ->
|
||||
if (switch) {
|
||||
isUserSMPServers = true
|
||||
} else {
|
||||
val userSMPServers = chatModel.userSMPServers.value
|
||||
if (userSMPServers != null) {
|
||||
if (userSMPServers.isNotEmpty()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.use_simplex_chat_servers__question),
|
||||
text = generalGetString(R.string.saved_SMP_servers_will_br_removed),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
onConfirm = {
|
||||
saveSMPServers(listOf())
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr.value = ""
|
||||
}
|
||||
)
|
||||
} else {
|
||||
isUserSMPServers = false
|
||||
userSMPServersStr.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cancelEdit = {
|
||||
val userSMPServers = chatModel.userSMPServers.value
|
||||
if (userSMPServers != null) {
|
||||
isUserSMPServers = userSMPServers.isNotEmpty()
|
||||
editSMPServers = !isUserSMPServers
|
||||
userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
|
||||
}
|
||||
},
|
||||
saveSMPServers = { saveSMPServers(it) },
|
||||
editOn = { editSMPServers = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SMPServersLayout(
|
||||
isUserSMPServers: Boolean,
|
||||
editSMPServers: Boolean,
|
||||
userSMPServersStr: MutableState<String>,
|
||||
isUserSMPServersOnOff: (Boolean) -> Unit,
|
||||
cancelEdit: () -> Unit,
|
||||
saveSMPServers: (List<String>) -> Unit,
|
||||
editOn: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.your_SMP_servers),
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
|
||||
Switch(
|
||||
checked = isUserSMPServers,
|
||||
onCheckedChange = isUserSMPServersOnOff,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUserSMPServers) {
|
||||
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
|
||||
} else {
|
||||
Text(stringResource(R.string.enter_one_SMP_server_per_line))
|
||||
if (editSMPServers) {
|
||||
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(R.string.cancel_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = cancelEdit)
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
stringResource(R.string.save_servers_button),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
val servers = userSMPServersStr.value.split("\n")
|
||||
saveSMPServers(servers)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.height(160.dp)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
||||
) {
|
||||
SelectionContainer(
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
userSMPServersStr.value,
|
||||
Modifier
|
||||
.padding(vertical = 5.dp, horizontal = 7.dp),
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
Text(
|
||||
stringResource(R.string.edit_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = editOn)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun howToButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
|
||||
) {
|
||||
Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
|
||||
Icon(
|
||||
Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(horizontal = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewSMPServersLayoutDefaultServers() {
|
||||
SimpleXTheme {
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = false,
|
||||
editSMPServers = true,
|
||||
userSMPServersStr = remember { mutableStateOf("") },
|
||||
isUserSMPServersOnOff = {},
|
||||
cancelEdit = {},
|
||||
saveSMPServers = {},
|
||||
editOn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewSMPServersLayoutUserServersEditOn() {
|
||||
SimpleXTheme {
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = true,
|
||||
editSMPServers = true,
|
||||
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
|
||||
isUserSMPServersOnOff = {},
|
||||
cancelEdit = {},
|
||||
saveSMPServers = {},
|
||||
editOn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewSMPServersLayoutUserServersEditOff() {
|
||||
SimpleXTheme {
|
||||
SMPServersLayout(
|
||||
isUserSMPServers = true,
|
||||
editSMPServers = false,
|
||||
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
|
||||
isUserSMPServersOnOff = {},
|
||||
cancelEdit = {},
|
||||
saveSMPServers = {},
|
||||
editOn = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.TerminalView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.SimpleXInfo
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
|
||||
fun setRunServiceInBackground(on: Boolean) {
|
||||
chatModel.controller.appPrefs.runServiceInBackground.set(on)
|
||||
if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
chatModel.runServiceInBackground.value = on
|
||||
}
|
||||
|
||||
if (user != null) {
|
||||
SettingsLayout(
|
||||
profile = user.profile,
|
||||
runServiceInBackground = chatModel.runServiceInBackground,
|
||||
setRunServiceInBackground = ::setRunServiceInBackground,
|
||||
setPerformLA = setPerformLA,
|
||||
enableCalls = remember { mutableStateOf(chatModel.controller.appPrefs.experimentalCalls.get()) },
|
||||
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
||||
showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(close = close, modifier = Modifier,
|
||||
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
|
||||
modalView(chatModel)
|
||||
}
|
||||
} } },
|
||||
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
|
||||
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val simplexTeamUri =
|
||||
"simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
|
||||
|
||||
@Composable
|
||||
fun SettingsLayout(
|
||||
profile: Profile,
|
||||
runServiceInBackground: MutableState<Boolean>,
|
||||
setRunServiceInBackground: (Boolean) -> Unit,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
enableCalls: MutableState<Boolean>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showTerminal: () -> Unit,
|
||||
// showVideoChatPrototype: () -> Unit
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Surface(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
@Composable fun spacer() = Spacer(Modifier.height(30.dp))
|
||||
Text(
|
||||
stringResource(R.string.your_settings),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
Spacer(Modifier.height(30.dp))
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_you)) {
|
||||
SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
|
||||
ProfilePreview(profile)
|
||||
}
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) })
|
||||
}
|
||||
spacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
if (enableCalls.value) {
|
||||
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) })
|
||||
divider()
|
||||
}
|
||||
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) })
|
||||
divider()
|
||||
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground)
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
|
||||
}
|
||||
spacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_help)) {
|
||||
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) })
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary)
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
|
||||
}
|
||||
spacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_develop)) {
|
||||
ChatConsoleItem(showTerminal)
|
||||
divider()
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
|
||||
divider()
|
||||
AppVersionItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) {
|
||||
Column {
|
||||
Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp)
|
||||
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
||||
Column(Modifier.padding(horizontal = 6.dp)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun PrivateNotificationsItem(
|
||||
runServiceInBackground: MutableState<Boolean>,
|
||||
setRunServiceInBackground: (Boolean) -> Unit
|
||||
) {
|
||||
SettingsItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(R.string.private_notifications),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
stringResource(R.string.private_notifications),
|
||||
Modifier
|
||||
.padding(end = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = runServiceInBackground.value,
|
||||
onCheckedChange = { setRunServiceInBackground(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
||||
SettingsItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Lock,
|
||||
contentDescription = stringResource(R.string.chat_lock),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
stringResource(R.string.chat_lock), Modifier
|
||||
.padding(end = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
)
|
||||
Switch(
|
||||
checked = performLA.value,
|
||||
onCheckedChange = { setPerformLA(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
|
||||
SettingsItemView(showTerminal) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||
contentDescription = stringResource(R.string.chat_console),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.chat_console))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||
SettingsItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun AppVersionItem() {
|
||||
SettingsItemView() {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary) {
|
||||
ProfileImage(size = size, image = profileOf.image, color = color)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Column {
|
||||
Text(
|
||||
profileOf.displayName,
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(profileOf.fullName)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) {
|
||||
val modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
Row(
|
||||
if (click == null) modifier else modifier.clickable(onClick = click),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified) {
|
||||
SettingsItemView(click) {
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(text, color = textColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
|
||||
SettingsItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
SharedPreferenceToggle(text, pref, prefState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSettingsLayout() {
|
||||
SimpleXTheme {
|
||||
SettingsLayout(
|
||||
profile = Profile.sampleData,
|
||||
runServiceInBackground = remember { mutableStateOf(true) },
|
||||
setRunServiceInBackground = {},
|
||||
setPerformLA = {},
|
||||
enableCalls = remember { mutableStateOf(true) },
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showCustomModal = { {} },
|
||||
showTerminal = {},
|
||||
// showVideoChatPrototype = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun UserAddressView(chatModel: ChatModel) {
|
||||
val cxt = LocalContext.current
|
||||
UserAddressLayout(
|
||||
userAddress = chatModel.userAddress.value,
|
||||
createAddress = {
|
||||
withApi {
|
||||
chatModel.userAddress.value = chatModel.controller.apiCreateUserAddress()
|
||||
}
|
||||
},
|
||||
share = { userAddress: String -> shareText(cxt, userAddress) },
|
||||
deleteAddress = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.delete_address__question),
|
||||
text = generalGetString(R.string.all_your_contacts_will_remain_connected),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.apiDeleteUserAddress()
|
||||
chatModel.userAddress.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserAddressLayout(
|
||||
userAddress: String?,
|
||||
createAddress: () -> Unit,
|
||||
share: (String) -> Unit,
|
||||
deleteAddress: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.your_chat_address),
|
||||
Modifier.padding(bottom = 16.dp),
|
||||
style = MaterialTheme.typography.h1,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.you_can_share_your_address_anybody_will_be_able_to_connect),
|
||||
Modifier.padding(bottom = 12.dp),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (userAddress == null) {
|
||||
Text(
|
||||
stringResource(R.string.if_you_delete_address_you_wont_lose_contacts),
|
||||
Modifier.padding(bottom = 12.dp),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
SimpleButton(stringResource(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress)
|
||||
} else {
|
||||
QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 10.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.share_link),
|
||||
icon = Icons.Outlined.Share,
|
||||
click = { share(userAddress) })
|
||||
SimpleButton(
|
||||
stringResource(R.string.delete_address),
|
||||
icon = Icons.Outlined.Delete,
|
||||
color = Color.Red,
|
||||
click = deleteAddress
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserAddressLayoutNoAddress() {
|
||||
SimpleXTheme {
|
||||
UserAddressLayout(
|
||||
userAddress = null,
|
||||
createAddress = {},
|
||||
share = { _ -> },
|
||||
deleteAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserAddressLayoutAddressCreated() {
|
||||
SimpleXTheme {
|
||||
UserAddressLayout(
|
||||
userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
|
||||
createAddress = {},
|
||||
share = { _ -> },
|
||||
deleteAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user