mirror of
https://gitlab.linphone.org/BC/public/linphone-android.git
synced 2026-01-21 05:28:08 +00:00
Compare commits
2074 commits
6.0.0-alph
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cb883a978 | ||
|
|
539439a055 | ||
|
|
597581faa5 | ||
|
|
5391c12473 | ||
|
|
cea47a767b | ||
|
|
e6f897cb39 | ||
|
|
97606578a4 | ||
|
|
48c9b4d7af | ||
|
|
7d0d1a22ee | ||
|
|
d467699388 | ||
|
|
5ad7b5da14 | ||
|
|
50c922b581 | ||
|
|
b574bf420c | ||
|
|
3df3c8d741 | ||
|
|
4039da9c8a | ||
|
|
3cec19126d | ||
|
|
a816a956b8 | ||
|
|
cec3639b73 | ||
|
|
07cae7eb12 | ||
|
|
3b561275a4 | ||
|
|
3f868e02fe | ||
|
|
57644a34de | ||
|
|
e306c8c7fc | ||
|
|
dd9190df07 | ||
|
|
d90861b5f3 | ||
|
|
9151898a4d | ||
|
|
fe788caf0e | ||
|
|
7e0353cc91 | ||
|
|
a897c127e5 | ||
|
|
50aa053c19 | ||
|
|
b88b6a8093 | ||
|
|
24d808b1a7 | ||
|
|
6f09853424 | ||
|
|
a7593e07fc | ||
|
|
844b182df2 | ||
|
|
d299b0b129 | ||
|
|
965b159139 | ||
|
|
00b8e59ade | ||
|
|
be47deeb40 | ||
|
|
e8c67fdd6f | ||
|
|
ff98c15840 | ||
|
|
3711fd749e | ||
|
|
dce7095f74 | ||
|
|
d2b12159af | ||
|
|
618be9ee7c | ||
|
|
e1abcc6dca | ||
|
|
2a9ef440b7 | ||
|
|
77b933c5a8 | ||
|
|
6b56165d4f | ||
|
|
61c79a86f7 | ||
|
|
40d195e06b | ||
|
|
e173e402c2 | ||
|
|
7817e6603c | ||
|
|
bf4b5a51f5 | ||
|
|
3ffda24b82 | ||
|
|
c99acbb5e1 | ||
|
|
cc1cc7d929 | ||
|
|
6bcce4ddbf | ||
|
|
696a593cbc | ||
|
|
88e474533e | ||
|
|
8e76c60a38 | ||
|
|
85aa50d8d8 | ||
|
|
c496545023 | ||
|
|
1183a9e1c2 | ||
|
|
170cd6fccc | ||
|
|
bc7ac8be64 | ||
|
|
5ed68e0171 | ||
|
|
41e6776b32 | ||
|
|
e290a8c4ea | ||
|
|
93e26f6c10 | ||
|
|
3f22a596db | ||
|
|
d5c836b8b5 | ||
|
|
9afcb6db15 | ||
|
|
a6f568497d | ||
|
|
209c0df091 | ||
|
|
7b0de4185c | ||
|
|
89458ed826 | ||
|
|
a3f86fbac0 | ||
|
|
28cee7f539 | ||
|
|
daa2f10f7b | ||
|
|
e14ea0ac68 | ||
|
|
c3ad96cd1f | ||
|
|
c6a0f25041 | ||
|
|
7ab7136a5b | ||
|
|
3698e1673e | ||
|
|
bdd5c8766b | ||
|
|
ce2b794936 | ||
|
|
e267f46fd7 | ||
|
|
ab6911dd11 | ||
|
|
b0283043ee | ||
|
|
0e71a726c1 | ||
|
|
d74ccb523e | ||
|
|
4dc1b9a903 | ||
|
|
45c756cfd6 | ||
|
|
069997d780 | ||
|
|
e2c9e1196f | ||
|
|
e8c642b9c6 | ||
|
|
d75c48cd34 | ||
|
|
d9ab840570 | ||
|
|
5ee3ba4ea9 | ||
|
|
d694789d4b | ||
|
|
b71249ea36 | ||
|
|
7855d4e1db | ||
|
|
7e2527c46c | ||
|
|
d16dbcf0fd | ||
|
|
1d28ce1846 | ||
|
|
2ea38abdfe | ||
|
|
416cc6ea7f | ||
|
|
6dc4790597 | ||
|
|
f8556aa46b | ||
|
|
df09bcad76 | ||
|
|
0ca4eba63b | ||
|
|
c556d14fb0 | ||
|
|
6cb78c8c59 | ||
|
|
61517461dd | ||
|
|
1fdc2bcc58 | ||
|
|
8f3415f6fa | ||
|
|
ae7a3c5bce | ||
|
|
31e15ddfca | ||
|
|
808dc92cd7 | ||
|
|
99936e8f75 | ||
|
|
2ce07b5e89 | ||
|
|
9d3ef9e8a5 | ||
|
|
5f17dd8534 | ||
|
|
719b28f0ab | ||
|
|
6f1439756e | ||
|
|
7fdbaf5fd6 | ||
|
|
4639e054bb | ||
|
|
504f6e2a2c | ||
|
|
1e6f501dee | ||
|
|
633aee829a | ||
|
|
38ffac31b4 | ||
|
|
6e40e3f75f | ||
|
|
7d7900e081 | ||
|
|
f0e899bb95 | ||
|
|
db7ca6793b | ||
|
|
ac521557ce | ||
|
|
b5babae39a | ||
|
|
ce13d4c7d4 | ||
|
|
e9cc03891b | ||
|
|
98bf3daed8 | ||
|
|
a5cee98a57 | ||
|
|
26e391cbf8 | ||
|
|
0add60c628 | ||
|
|
fc90a95e94 | ||
|
|
c153b2d928 | ||
|
|
881e2c217b | ||
|
|
77d744d020 | ||
|
|
5a16761fdf | ||
|
|
e0ff593f3d | ||
|
|
71bb569fde | ||
|
|
9096225b45 | ||
|
|
ad0037fe4c | ||
|
|
7eed9c06d3 | ||
|
|
e2dabf5448 | ||
|
|
08c72dbb8c | ||
|
|
f99b51d572 | ||
|
|
8f0f6581b2 | ||
|
|
fac6e42c22 | ||
|
|
e8d3c8750a | ||
|
|
dee932da42 | ||
|
|
2f4fd3da18 | ||
|
|
22ae4e372f | ||
|
|
95bd14bdd4 | ||
|
|
ffabd02f31 | ||
|
|
0bb7761db9 | ||
|
|
50418f5dbb | ||
|
|
e800249445 | ||
|
|
ec5b6e5707 | ||
|
|
97c6c0b553 | ||
|
|
95ce77e0e4 | ||
|
|
243a6d8cb2 | ||
|
|
e98318a23d | ||
|
|
ace0a3f61e | ||
|
|
b3ac16052f | ||
|
|
865216d717 | ||
|
|
670eecf0d6 | ||
|
|
8dcb18d059 | ||
|
|
92672bde0a | ||
|
|
ada6f35d92 | ||
|
|
332828dc7c | ||
|
|
595ff96d50 | ||
|
|
6cdcdec373 | ||
|
|
3098c3e68e | ||
|
|
2c9d627794 | ||
|
|
70df098ee4 | ||
|
|
c32bac7b07 | ||
|
|
3d41a4d221 | ||
|
|
dfa87e4088 | ||
|
|
d6b43c474b | ||
|
|
9d0f2cafc9 | ||
|
|
98cc173d2e | ||
|
|
4ae046a166 | ||
|
|
62180140b7 | ||
|
|
899129d4bc | ||
|
|
c4618702ab | ||
|
|
8c7c7b40c3 | ||
|
|
856f3e7f94 | ||
|
|
f7be887984 | ||
|
|
028ece407c | ||
|
|
bb81957aab | ||
|
|
da581c3737 | ||
|
|
461537aa9c | ||
|
|
1a54746a80 | ||
|
|
be24224f4c | ||
|
|
f2cdb92858 | ||
|
|
aa0255bcfd | ||
|
|
ff425089c7 | ||
|
|
b4c2a52bf7 | ||
|
|
f397456879 | ||
|
|
5337ab6413 | ||
|
|
998f969c0f | ||
|
|
58410ee112 | ||
|
|
6c97ee9176 | ||
|
|
187946bf34 | ||
|
|
3c40bf3d6f | ||
|
|
b7a9f4ba8e | ||
|
|
4a1c5304b1 | ||
|
|
67e3c51a84 | ||
|
|
4d8ab32da7 | ||
|
|
62ff36e7a7 | ||
|
|
6f80409086 | ||
|
|
fd3f746e3d | ||
|
|
42fbbc51fd | ||
|
|
60c74ee5b2 | ||
|
|
79212a8757 | ||
|
|
1307ec5471 | ||
|
|
c62f549521 | ||
|
|
9ba2684f31 | ||
|
|
4b631a19ef | ||
|
|
496279d724 | ||
|
|
c25ed404dc | ||
|
|
a81973e4cf | ||
|
|
654e790a6d | ||
|
|
6602c7692b | ||
|
|
cc57244b56 | ||
|
|
19df3b07dc | ||
|
|
4ef9a2bdf3 | ||
|
|
61be1d21d5 | ||
|
|
8148354901 | ||
|
|
ae39d79420 | ||
|
|
2bd0de4af1 | ||
|
|
cb27b35984 | ||
|
|
316bc6698a | ||
|
|
fea1dbe5ca | ||
|
|
85679dcc43 | ||
|
|
32060f6830 | ||
|
|
dfdc26a575 | ||
|
|
9e1d358f4e | ||
|
|
90922568b5 | ||
|
|
d212b7b06e | ||
|
|
4cb83980ba | ||
|
|
def52f69ad | ||
|
|
17ce34aba7 | ||
|
|
1833b1985d | ||
|
|
5256ee79c6 | ||
|
|
27e59a5f8b | ||
|
|
cea2d49778 | ||
|
|
c0f67d01fe | ||
|
|
b6279b03c0 | ||
|
|
21398c7b37 | ||
|
|
4cb7ea1965 | ||
|
|
7aae03f1f9 | ||
|
|
25d13f44c7 | ||
|
|
81d0da4241 | ||
|
|
1556abc79e | ||
|
|
28b6bd7e90 | ||
|
|
1c3173b871 | ||
|
|
17588de5a9 | ||
|
|
502c6413ee | ||
|
|
02cbb45de9 | ||
|
|
f5852a7b3e | ||
|
|
cfec621787 | ||
|
|
6847227f1a | ||
|
|
f1fdb186ec | ||
|
|
d822cbc827 | ||
|
|
627f881364 | ||
|
|
244061c0b1 | ||
|
|
dc2b94ca4d | ||
|
|
7c78b021db | ||
|
|
d113797dfb | ||
|
|
926b8d4dc1 | ||
|
|
85e24e25bf | ||
|
|
1c1729f3f0 | ||
|
|
bcce9a9ba1 | ||
|
|
a496e2bf56 | ||
|
|
73237ee335 | ||
|
|
b293bf7f2f | ||
|
|
4689b7c7da | ||
|
|
e38040428b | ||
|
|
b740409642 | ||
|
|
99a5ed23f6 | ||
|
|
c9a3a01733 | ||
|
|
966f713f19 | ||
|
|
344afdfcfa | ||
|
|
2634945b8d | ||
|
|
2713c82ca3 | ||
|
|
1a813ee11e | ||
|
|
e5cec2d45c | ||
|
|
6e9c6d1b33 | ||
|
|
056abd629f | ||
|
|
6c86af747b | ||
|
|
f7790fbed7 | ||
|
|
90524da610 | ||
|
|
616b7bb70f | ||
|
|
985a304df9 | ||
|
|
cd35f213c1 | ||
|
|
dcbc837106 | ||
|
|
5ef7eab0c5 | ||
|
|
c64bd5bc1c | ||
|
|
afa041baf6 | ||
|
|
94b6db6a08 | ||
|
|
6ba8760be7 | ||
|
|
b1b1ab0d8a | ||
|
|
518ecc1823 | ||
|
|
e2dfd95857 | ||
|
|
51d725c757 | ||
|
|
af3b1fa418 | ||
|
|
bc9a6581b1 | ||
|
|
26df085df3 | ||
|
|
c08157b659 | ||
|
|
910527ef1b | ||
|
|
8577571e67 | ||
|
|
dbca62bea9 | ||
|
|
5e9be7d10b | ||
|
|
836deaae99 | ||
|
|
a2680028ce | ||
|
|
c8ff7262d4 | ||
|
|
06d8e903fc | ||
|
|
a5872ef8de | ||
|
|
9255830fe2 | ||
|
|
80eaf08fbf | ||
|
|
903aaad6fe | ||
|
|
bdb2615300 | ||
|
|
bab2acb75c | ||
|
|
bd52960749 | ||
|
|
23810e41e5 | ||
|
|
3f3a229844 | ||
|
|
0eb659b633 | ||
|
|
c35a44b1a0 | ||
|
|
1cccf7d26b | ||
|
|
0fab732e89 | ||
|
|
317a7c4417 | ||
|
|
689665c475 | ||
|
|
18e15b60a4 | ||
|
|
7bead679ad | ||
|
|
f0ad67fb29 | ||
|
|
2621eb306e | ||
|
|
90bf20e50e | ||
|
|
1f45ba8bd0 | ||
|
|
c528f0cdb8 | ||
|
|
a0108776dd | ||
|
|
a503ef06ee | ||
|
|
fbc19c7053 | ||
|
|
9c8c5f309e | ||
|
|
b40fbcad77 | ||
|
|
8dda38a925 | ||
|
|
d150027c24 | ||
|
|
7018cd3442 | ||
|
|
d6494cd27c | ||
|
|
10f2d7cd78 | ||
|
|
6767bc09f9 | ||
|
|
b22ab7024e | ||
|
|
c6fa645f94 | ||
|
|
fb3feb0bc3 | ||
|
|
77f61c1cfa | ||
|
|
9ce803667b | ||
|
|
50bd8f67d5 | ||
|
|
faac4111d9 | ||
|
|
6121cb41bf | ||
|
|
2aed404167 | ||
|
|
2f9eb2f0ab | ||
|
|
1255d626af | ||
|
|
a83f9d4424 | ||
|
|
fecf067b50 | ||
|
|
b194272f91 | ||
|
|
cad90752db | ||
|
|
2abad0ab9a | ||
|
|
a0d74c8036 | ||
|
|
2eb376fd2d | ||
|
|
1942ee8f85 | ||
|
|
488a0fd98c | ||
|
|
08412ef99a | ||
|
|
e16e767d5a | ||
|
|
886be9e038 | ||
|
|
2a5b5d368c | ||
|
|
be5428aa08 | ||
|
|
d6c6de2b5e | ||
|
|
915a847083 | ||
|
|
0d8397b914 | ||
|
|
b5a1e21f40 | ||
|
|
9837a834d4 | ||
|
|
8a4956e7c1 | ||
|
|
052d7cc522 | ||
|
|
6c6fb9eff3 | ||
|
|
b23f52adec | ||
|
|
8769a47ed0 | ||
|
|
ebb7201701 | ||
|
|
6e83b794b3 | ||
|
|
3045378eb0 | ||
|
|
dc4619a7d7 | ||
|
|
87b6c2deef | ||
|
|
614ac7f9cf | ||
|
|
71e1734ca0 | ||
|
|
71b1cf8e7a | ||
|
|
dee684b364 | ||
|
|
0b6805a73c | ||
|
|
11795cded8 | ||
|
|
cc5bfcf14d | ||
|
|
b3ab9601b2 | ||
|
|
0e6d91a467 | ||
|
|
fbf68db2dd | ||
|
|
0bf50f1495 | ||
|
|
8363d41441 | ||
|
|
c1e20af56d | ||
|
|
5d14d8bb7b | ||
|
|
a99067f701 | ||
|
|
2ceebdcdda | ||
|
|
dca10c9b2a | ||
|
|
82e341ea8c | ||
|
|
508f1154f5 | ||
|
|
07cb09128e | ||
|
|
d6d705a975 | ||
|
|
c709c720e4 | ||
|
|
96a07fa8c6 | ||
|
|
141d7b08a6 | ||
|
|
3c21044cf0 | ||
|
|
e0e7032827 | ||
|
|
4cca59a39f | ||
|
|
4e852601fc | ||
|
|
30364c48b0 | ||
|
|
354f39d76d | ||
|
|
64a2c5f455 | ||
|
|
9ffe3b4d7f | ||
|
|
fb323a4606 | ||
|
|
7d6c50cf29 | ||
|
|
8b9ceef6da | ||
|
|
12e6de52a8 | ||
|
|
990549eb24 | ||
|
|
4afa2ebc93 | ||
|
|
e05d4cf94a | ||
|
|
01c079440d | ||
|
|
8eda500dae | ||
|
|
a392cf3a6b | ||
|
|
977fb63693 | ||
|
|
fcd365ad81 | ||
|
|
f1b23337e0 | ||
|
|
296a324ba3 | ||
|
|
7e2a4c124d | ||
|
|
b8a6177f97 | ||
|
|
f3991fb29d | ||
|
|
0bf6d60570 | ||
|
|
ef79475525 | ||
|
|
ec9c7bd070 | ||
|
|
875198164d | ||
|
|
17511a4c26 | ||
|
|
ca08ed68be | ||
|
|
e292b0d7e8 | ||
|
|
8c1889b181 | ||
|
|
037cd71814 | ||
|
|
40e9dfc522 | ||
|
|
7e0c6a23a9 | ||
|
|
7ccd42580d | ||
|
|
a238ae8db0 | ||
|
|
a210ea67c1 | ||
|
|
dd454113e8 | ||
|
|
690140c2b8 | ||
|
|
ff323cea68 | ||
|
|
fede808df1 | ||
|
|
ba5786fa0a | ||
|
|
22b447a67f | ||
|
|
ea79e9243d | ||
|
|
709f7dd3c5 | ||
|
|
9381b459a0 | ||
|
|
7bf9eb8394 | ||
|
|
30fc60c0ef | ||
|
|
3e5a0c22f8 | ||
|
|
2b75ecdaca | ||
|
|
6afd711539 | ||
|
|
bfc435c350 | ||
|
|
0f59e1a381 | ||
|
|
dd7546484b | ||
|
|
86b35354c3 | ||
|
|
3e91f3e5ff | ||
|
|
74394445c9 | ||
|
|
536667cfe1 | ||
|
|
b057163b43 | ||
|
|
63051ae58e | ||
|
|
00a7d34509 | ||
|
|
da385ee6e1 | ||
|
|
2952b2db01 | ||
|
|
2ed4f44e50 | ||
|
|
1c09388266 | ||
|
|
e5795ea05f | ||
|
|
0625239477 | ||
|
|
5e6186d115 | ||
|
|
28998d4463 | ||
|
|
6121040bda | ||
|
|
0f3aea191f | ||
|
|
e9385b5c07 | ||
|
|
ec68e931c4 | ||
|
|
37c23066f0 | ||
|
|
a5b8a8a683 | ||
|
|
6d08625168 | ||
|
|
b4329af83a | ||
|
|
de5f44ba04 | ||
|
|
e686ed90e9 | ||
|
|
f5ba48b9f0 | ||
|
|
4dd92cddf0 | ||
|
|
1ead4d9218 | ||
|
|
6a41f7f67d | ||
|
|
febc6a85d3 | ||
|
|
e1bcae703c | ||
|
|
d05da148e7 | ||
|
|
ec9984c86b | ||
|
|
456181609b | ||
|
|
f7d65b102e | ||
|
|
06d1ae81b5 | ||
|
|
8477980011 | ||
|
|
e283b7b48e | ||
|
|
fa9bcd3475 | ||
|
|
7f49a7756c | ||
|
|
e7a4a24eaf | ||
|
|
e938105db0 | ||
|
|
ca7035d84a | ||
|
|
27b4fa63f9 | ||
|
|
e464a49176 | ||
|
|
15f9d04747 | ||
|
|
9504c6d1ca | ||
|
|
d0c733e81b | ||
|
|
e85c97837f | ||
|
|
375c020b9b | ||
|
|
fee595cfdc | ||
|
|
dbb1793ea0 | ||
|
|
4b0fcd38d7 | ||
|
|
3a3518b2a5 | ||
|
|
788dd338fb | ||
|
|
2e89f4d101 | ||
|
|
8b183fd347 | ||
|
|
815a2ed854 | ||
|
|
2ba54f085d | ||
|
|
ae80e45e1c | ||
|
|
7967a97b01 | ||
|
|
d5e20c02fe | ||
|
|
00fbfcc490 | ||
|
|
17e3622bdc | ||
|
|
6b7591c971 | ||
|
|
633fa07164 | ||
|
|
fbe5885d08 | ||
|
|
099350244a | ||
|
|
eac72a0e42 | ||
|
|
de29fbc125 | ||
|
|
864677dab1 | ||
|
|
c500761940 | ||
|
|
e7f888ad78 | ||
|
|
32a3feef42 | ||
|
|
e379833e18 | ||
|
|
486706299d | ||
|
|
b1a03db96f | ||
|
|
5cee11c779 | ||
|
|
a9736df3eb | ||
|
|
da848ecc61 | ||
|
|
942e78eede | ||
|
|
b084807d18 | ||
|
|
d0dc42c67b | ||
|
|
83f249b1d9 | ||
|
|
8f52182959 | ||
|
|
2c9a15a007 | ||
|
|
5d96ea3b56 | ||
|
|
e1c4fc2525 | ||
|
|
049f63b61e | ||
|
|
eebc1bc91e | ||
|
|
0d5bf5ed3e | ||
|
|
bad1621126 | ||
|
|
8e93fefda3 | ||
|
|
ea92ca2220 | ||
|
|
3734c8ca90 | ||
|
|
aad50669c4 | ||
|
|
679f125870 | ||
|
|
a90dd53c9c | ||
|
|
e664e1802c | ||
|
|
20fc177c7a | ||
|
|
7c755bd080 | ||
|
|
372a16d3ab | ||
|
|
d4b0356bda | ||
|
|
31b9836cc0 | ||
|
|
0571862142 | ||
|
|
0497237630 | ||
|
|
75905c1321 | ||
|
|
676a2c7710 | ||
|
|
967fb0563d | ||
|
|
3fb5d8a97b | ||
|
|
4682a75049 | ||
|
|
466395a34b | ||
|
|
2380c0fa6d | ||
|
|
137e8941cb | ||
|
|
409baab7c4 | ||
|
|
f068b5c9ce | ||
|
|
3cdcc4bb77 | ||
|
|
a3ed13bc79 | ||
|
|
3a23c8813e | ||
|
|
9d58ced715 | ||
|
|
86e2c731da | ||
|
|
5593e06cf5 | ||
|
|
7bfed48b23 | ||
|
|
821986b6a9 | ||
|
|
b9065d3c7c | ||
|
|
47a308dcfc | ||
|
|
9f6fb13e80 | ||
|
|
6d3ef66995 | ||
|
|
e57ff021e9 | ||
|
|
d5ca4c29d3 | ||
|
|
b3da6ed347 | ||
|
|
b6e36272f7 | ||
|
|
41081f565a | ||
|
|
b172396f0d | ||
|
|
95b6a8e7a5 | ||
|
|
d6ce9250fc | ||
|
|
0e057d8aa4 | ||
|
|
e0c58c0ac5 | ||
|
|
4304552bc8 | ||
|
|
ab1d271b76 | ||
|
|
b5b57405c6 | ||
|
|
6476bb518d | ||
|
|
f9cf90fecd | ||
|
|
e5f227ba35 | ||
|
|
d718cff486 | ||
|
|
3167544873 | ||
|
|
af4ff25310 | ||
|
|
75bb28cb2f | ||
|
|
935d463896 | ||
|
|
c988504319 | ||
|
|
5bcf0e8ddb | ||
|
|
a5f846a26d | ||
|
|
877565e516 | ||
|
|
3a7265295e | ||
|
|
46dc7355b2 | ||
|
|
d80b023918 | ||
|
|
c4eb74ff0d | ||
|
|
f48da167ab | ||
|
|
6b11e37b14 | ||
|
|
5a1487a691 | ||
|
|
badbbde183 | ||
|
|
713ca4c1d5 | ||
|
|
d312141eda | ||
|
|
4f0ca4adca | ||
|
|
a4c897b47d | ||
|
|
711f2f4200 | ||
|
|
49d8381705 | ||
|
|
0ce886cb56 | ||
|
|
40541d3316 | ||
|
|
11e44d1fc4 | ||
|
|
d864b5efa5 | ||
|
|
0b0d7ce85a | ||
|
|
363ce834fa | ||
|
|
8bb6b61edc | ||
|
|
2d2a5e26f6 | ||
|
|
213e62d125 | ||
|
|
8d78f2b698 | ||
|
|
f3a2020466 | ||
|
|
167b42810f | ||
|
|
d71966e77d | ||
|
|
cf901a9c2b | ||
|
|
a743a0d2c6 | ||
|
|
601aaf0b5c | ||
|
|
f65666a996 | ||
|
|
4f5ea3b5a4 | ||
|
|
90971224d5 | ||
|
|
1f08682340 | ||
|
|
1e4066163f | ||
|
|
b7e7e08bbe | ||
|
|
eb80a16202 | ||
|
|
a7fb2ccfec | ||
|
|
4afb10d82a | ||
|
|
f74976f563 | ||
|
|
a96940b94a | ||
|
|
3fcbc9bf28 | ||
|
|
a2bf235656 | ||
|
|
40093d77cf | ||
|
|
e205d0ef00 | ||
|
|
416f4ffef0 | ||
|
|
68fe26e05f | ||
|
|
cda5deb18d | ||
|
|
5c8e4bcc22 | ||
|
|
70b4a500d2 | ||
|
|
135ca527ee | ||
|
|
c88917ac68 | ||
|
|
d02ca882bd | ||
|
|
807a36b54c | ||
|
|
5f6c02e2ca | ||
|
|
9a33def8a4 | ||
|
|
88b4227bbc | ||
|
|
5da87f598a | ||
|
|
612c8b3301 | ||
|
|
6ad5f7573b | ||
|
|
2be4f691f2 | ||
|
|
eeb19846cc | ||
|
|
467e029599 | ||
|
|
4ff8c7c7eb | ||
|
|
cc6ec98846 | ||
|
|
257352927d | ||
|
|
50cb162bd3 | ||
|
|
e562f1505d | ||
|
|
d5d3cc0bc2 | ||
|
|
d03f94d52a | ||
|
|
6edd20e214 | ||
|
|
0217ca78a0 | ||
|
|
c435852b32 | ||
|
|
c6fe442b09 | ||
|
|
6d23402001 | ||
|
|
e3d356765d | ||
|
|
d446e6d998 | ||
|
|
157ea2c847 | ||
|
|
97f12b150a | ||
|
|
6826a51307 | ||
|
|
8a39529fd1 | ||
|
|
865f3b9692 | ||
|
|
b0dbfbcc3d | ||
|
|
7d9a3edf31 | ||
|
|
d0ded694f8 | ||
|
|
899a3ea374 | ||
|
|
27b1cf90c6 | ||
|
|
6d959d489b | ||
|
|
9e08a5a506 | ||
|
|
86e09b5dce | ||
|
|
248a06c8be | ||
|
|
e8100e58da | ||
|
|
10c68a2c28 | ||
|
|
cfe00d1b61 | ||
|
|
20daa67ccf | ||
|
|
42cf8fd89e | ||
|
|
bb4e9fdeb5 | ||
|
|
cd8785855d | ||
|
|
fe2a074d0b | ||
|
|
63cb7d6630 | ||
|
|
eaa498f1ad | ||
|
|
f6d4f56bbc | ||
|
|
31d92abcdf | ||
|
|
92ec1940e5 | ||
|
|
5ebb4ee6ac | ||
|
|
a9d11543d8 | ||
|
|
0e451dcc8a | ||
|
|
e74ebb0d81 | ||
|
|
4f10a2d1fc | ||
|
|
690f6fa4c2 | ||
|
|
45cfac3ac6 | ||
|
|
db2ce910b9 | ||
|
|
0baceddf27 | ||
|
|
fa7486ff36 | ||
|
|
b2b55305d2 | ||
|
|
5eece24d68 | ||
|
|
51dd246971 | ||
|
|
3190a1869d | ||
|
|
2c2beb5725 | ||
|
|
7b8b92706b | ||
|
|
8fc3185278 | ||
|
|
96d85027c3 | ||
|
|
41721e3994 | ||
|
|
1a35a7048d | ||
|
|
49059d6b3c | ||
|
|
c0e8bb6c1a | ||
|
|
1ef4e57d9e | ||
|
|
85a61839bc | ||
|
|
30ab0fa827 | ||
|
|
439e115338 | ||
|
|
722840f1c5 | ||
|
|
db751efa91 | ||
|
|
6c22b1f66d | ||
|
|
9fc9574369 | ||
|
|
ce4fed2197 | ||
|
|
e03dcf3f88 | ||
|
|
d434ab7298 | ||
|
|
4673db9e4e | ||
|
|
6893d35a93 | ||
|
|
4cbad5308c | ||
|
|
7fb3a6ada3 | ||
|
|
ace4caca3f | ||
|
|
95c4106bd0 | ||
|
|
c2e1333be1 | ||
|
|
b4a754db53 | ||
|
|
49c3e68f84 | ||
|
|
3359fbcbd7 | ||
|
|
6308d66eb6 | ||
|
|
4628560411 | ||
|
|
84af9437bc | ||
|
|
8a1d88c4b5 | ||
|
|
5a7872222d | ||
|
|
5d7addb8d8 | ||
|
|
bed9288f21 | ||
|
|
ed1cf58fd4 | ||
|
|
a29777eebc | ||
|
|
1f604d54f2 | ||
|
|
245b848c91 | ||
|
|
34e1f0070e | ||
|
|
fb9d1c5c0d | ||
|
|
b301a5227a | ||
|
|
94f2c1cc98 | ||
|
|
cbaf7673f5 | ||
|
|
4d190cabdd | ||
|
|
5c77b58154 | ||
|
|
ce1c3dad65 | ||
|
|
4a75315240 | ||
|
|
317bbb470b | ||
|
|
d8d424d446 | ||
|
|
ff81a1c615 | ||
|
|
769006b043 | ||
|
|
c40c15b66f | ||
|
|
7c3bed7dd4 | ||
|
|
674fa1f41b | ||
|
|
ab1ea3392d | ||
|
|
2821d4b72f | ||
|
|
9ed2415d1b | ||
|
|
859e32e655 | ||
|
|
f325c5ebbd | ||
|
|
be849e0c80 | ||
|
|
de9a2318af | ||
|
|
9ec927c0c4 | ||
|
|
453b986f82 | ||
|
|
b123082559 | ||
|
|
27298639c3 | ||
|
|
2c09158977 | ||
|
|
bf4ab1b412 | ||
|
|
ce7779f720 | ||
|
|
8c8f15b02d | ||
|
|
ded00052b5 | ||
|
|
e0dc53564e | ||
|
|
349167868f | ||
|
|
00d14feded | ||
|
|
48fd2ba1f8 | ||
|
|
dced2dae7e | ||
|
|
00dd62553b | ||
|
|
a855c569fb | ||
|
|
2021c5e102 | ||
|
|
bd93f0ed71 | ||
|
|
5889389866 | ||
|
|
2104d79e1c | ||
|
|
415da6f03d | ||
|
|
1d0b5a5d4d | ||
|
|
e5c7fa07cc | ||
|
|
9c40709666 | ||
|
|
4978c9a16d | ||
|
|
196a010f36 | ||
|
|
b58a23b60d | ||
|
|
12c112fa39 | ||
|
|
3d8bc10499 | ||
|
|
fdee7e618e | ||
|
|
5ce7a5524a | ||
|
|
ef32dac910 | ||
|
|
ea49d3a411 | ||
|
|
6280ed5f3d | ||
|
|
51d9b18c1c | ||
|
|
6746e71197 | ||
|
|
57f3b0c78b | ||
|
|
455db9b9eb | ||
|
|
c8d9248e0c | ||
|
|
a7f868fe15 | ||
|
|
59aa036875 | ||
|
|
1c8a376c7f | ||
|
|
1ee5993624 | ||
|
|
247788c64c | ||
|
|
505fa3b66c | ||
|
|
a676c51401 | ||
|
|
8e588bf800 | ||
|
|
f29f5f9bc7 | ||
|
|
a136b7da8b | ||
|
|
bc38ff19b6 | ||
|
|
9e3a7055fe | ||
|
|
36cc74956e | ||
|
|
6c20ac8d40 | ||
|
|
59ebff6d15 | ||
|
|
b55d7070b0 | ||
|
|
55cd29e710 | ||
|
|
8a410fc77f | ||
|
|
5cc491335c | ||
|
|
e14a142fb7 | ||
|
|
f3b6627635 | ||
|
|
3ec942c475 | ||
|
|
ce5a5c5ddc | ||
|
|
a53542c092 | ||
|
|
ae59911f0a | ||
|
|
e29d64a02b | ||
|
|
4c66012372 | ||
|
|
9465593aa6 | ||
|
|
2394c701cf | ||
|
|
0bea45054b | ||
|
|
756a83797f | ||
|
|
87f4ebbd4c | ||
|
|
5689557487 | ||
|
|
e85488cf65 | ||
|
|
83df46ca8b | ||
|
|
1d328f98e3 | ||
|
|
b5b37bd74d | ||
|
|
a6b510e536 | ||
|
|
22795a5284 | ||
|
|
4c9bdec61e | ||
|
|
a73e483478 | ||
|
|
8ce91d1300 | ||
|
|
79ca1d6505 | ||
|
|
7d40ad5ad1 | ||
|
|
681c0f22c3 | ||
|
|
2086fbad66 | ||
|
|
fb86cd9bcb | ||
|
|
b9addcf683 | ||
|
|
6c757a9637 | ||
|
|
6cabf0bdf7 | ||
|
|
179a6c39ca | ||
|
|
8a92624254 | ||
|
|
7aebfafde3 | ||
|
|
1cd79b5086 | ||
|
|
25be5e1814 | ||
|
|
c7032b6000 | ||
|
|
bf1140fb38 | ||
|
|
e012d45c10 | ||
|
|
11eb0199f8 | ||
|
|
949d870d7e | ||
|
|
0cfdedc09a | ||
|
|
d8c406320c | ||
|
|
a278333eb4 | ||
|
|
0d5b189978 | ||
|
|
e1852f4ae4 | ||
|
|
964a597aa1 | ||
|
|
5ae345e794 | ||
|
|
5289dc4824 | ||
|
|
f94b57d304 | ||
|
|
72fd952a55 | ||
|
|
811a0466d4 | ||
|
|
f449763c4e | ||
|
|
c4fa68858c | ||
|
|
77e99fbfd3 | ||
|
|
af3cab475b | ||
|
|
57f8ff3341 | ||
|
|
54ee456f8e | ||
|
|
f3eb821946 | ||
|
|
a788191e50 | ||
|
|
94e6b28f4f | ||
|
|
ce3c37ad15 | ||
|
|
af6cfdfc18 | ||
|
|
515b645b89 | ||
|
|
c6550b6256 | ||
|
|
e3f1611a0f | ||
|
|
08052d64fd | ||
|
|
8f34c3ea5c | ||
|
|
ceb3679975 | ||
|
|
72f8574a1e | ||
|
|
ec6316f6e5 | ||
|
|
4bd6dc4e0f | ||
|
|
cad05a0c83 | ||
|
|
c68db48de9 | ||
|
|
e80c0e6068 | ||
|
|
b3464433ed | ||
|
|
5cd0a741ab | ||
|
|
60a3752fe8 | ||
|
|
217f116324 | ||
|
|
1b644741c6 | ||
|
|
c7586feebc | ||
|
|
5b80833f30 | ||
|
|
a858cffc82 | ||
|
|
974e0cffc6 | ||
|
|
9f36ec950f | ||
|
|
791e209d62 | ||
|
|
cb589b95c8 | ||
|
|
5e727b4e42 | ||
|
|
058a88424c | ||
|
|
3ccf4dc3a6 | ||
|
|
b7996b9e82 | ||
|
|
274ed49f16 | ||
|
|
fb75ea344c | ||
|
|
483e88a02d | ||
|
|
ef9339e912 | ||
|
|
896b621545 | ||
|
|
12e7041c57 | ||
|
|
01f6ac29e9 | ||
|
|
a71ba2096b | ||
|
|
ee4e332330 | ||
|
|
f9f25b2b15 | ||
|
|
87bfe5b6c4 | ||
|
|
88c230f136 | ||
|
|
cfe7a8ed38 | ||
|
|
a3489f4064 | ||
|
|
ab9aedf5ee | ||
|
|
aab5863b57 | ||
|
|
0238de5b3b | ||
|
|
b8e2541cda | ||
|
|
87153679a3 | ||
|
|
91df15c022 | ||
|
|
8f4c5bdc61 | ||
|
|
84b39df26c | ||
|
|
1741ed5fe5 | ||
|
|
56d8e64762 | ||
|
|
78f1a1e645 | ||
|
|
37786a0b83 | ||
|
|
824b225549 | ||
|
|
4c127344c1 | ||
|
|
61c1079e7c | ||
|
|
dcbd978184 | ||
|
|
fa6d93076c | ||
|
|
a95c6fb287 | ||
|
|
e039a562fe | ||
|
|
69d149a284 | ||
|
|
6d099a3075 | ||
|
|
eea978d8e8 | ||
|
|
46a6751df6 | ||
|
|
1594252aeb | ||
|
|
ec207af81d | ||
|
|
cda23c44c0 | ||
|
|
144fc5d728 | ||
|
|
a424f05ab9 | ||
|
|
481e8db0aa | ||
|
|
f6e6914656 | ||
|
|
75ce3a9c05 | ||
|
|
e83afdf436 | ||
|
|
5d58b2a0fd | ||
|
|
133f533b87 | ||
|
|
c927a83aa5 | ||
|
|
3a2757d962 | ||
|
|
2e8b258b90 | ||
|
|
37df7d83e3 | ||
|
|
dddfdd8b78 | ||
|
|
76716503e9 | ||
|
|
26e1332421 | ||
|
|
6cfec04424 | ||
|
|
bf64b496c9 | ||
|
|
4f6c5c7f48 | ||
|
|
d48f7697df | ||
|
|
99e771898a | ||
|
|
7cf51f51a1 | ||
|
|
fc8ac5fc56 | ||
|
|
7cff514c3a | ||
|
|
3060af3940 | ||
|
|
458f817142 | ||
|
|
701117474e | ||
|
|
e93e6ca5cc | ||
|
|
f8d60c1284 | ||
|
|
01d721d477 | ||
|
|
6ecc0839ea | ||
|
|
1d5e496f3f | ||
|
|
3adbd83259 | ||
|
|
376af91e88 | ||
|
|
26e30c6060 | ||
|
|
f6545f5641 | ||
|
|
31580e6291 | ||
|
|
4f848b182a | ||
|
|
d5f43323a2 | ||
|
|
f90b518f43 | ||
|
|
8f1eeebd66 | ||
|
|
9a97a45448 | ||
|
|
1ea32e7544 | ||
|
|
80da408930 | ||
|
|
8303b356da | ||
|
|
a557875ce8 | ||
|
|
4d561a4635 | ||
|
|
fa7d4bc267 | ||
|
|
876029cbc9 | ||
|
|
e9f7e4dcf5 | ||
|
|
82071985bc | ||
|
|
10b7545807 | ||
|
|
ccd53b74db | ||
|
|
eb0748df7f | ||
|
|
ecad8fbdce | ||
|
|
655cc8c291 | ||
|
|
889b98db1e | ||
|
|
465201010d | ||
|
|
b3ef701661 | ||
|
|
d062910133 | ||
|
|
405596d291 | ||
|
|
63b06ed1fa | ||
|
|
bb844739c1 | ||
|
|
4fe0487eaf | ||
|
|
121f400732 | ||
|
|
1a5bc838b9 | ||
|
|
552e158979 | ||
|
|
d8f0338f7c | ||
|
|
54a775e0fd | ||
|
|
9c7739cf6c | ||
|
|
22254b7846 | ||
|
|
fde0ca60a1 | ||
|
|
faf20eb369 | ||
|
|
68f2535072 | ||
|
|
9df97d7594 | ||
|
|
efbcfee316 | ||
|
|
c9c362d570 | ||
|
|
65a3d0520f | ||
|
|
86beb60507 | ||
|
|
78052cae12 | ||
|
|
65595a1d51 | ||
|
|
2b315cd0e4 | ||
|
|
b880578f99 | ||
|
|
3343b728fa | ||
|
|
3b588f467a | ||
|
|
fec0766501 | ||
|
|
ffa9a909a9 | ||
|
|
507fb8a3ce | ||
|
|
ebf9fa9145 | ||
|
|
9ca6978b7c | ||
|
|
d55c60cd98 | ||
|
|
a9c28aa1aa | ||
|
|
2cef97bce1 | ||
|
|
ed541bf5c0 | ||
|
|
107388584b | ||
|
|
1719a57cdc | ||
|
|
b4d25b0e6e | ||
|
|
b4baddcc5b | ||
|
|
cf7dbb7f61 | ||
|
|
7633af198a | ||
|
|
20f4a072c4 | ||
|
|
0c7a6bb3b3 | ||
|
|
ca475c0ab8 | ||
|
|
7e06c5d85a | ||
|
|
0239d3a3a6 | ||
|
|
0dbd403d5b | ||
|
|
39fba60066 | ||
|
|
8e2fc8b6cd | ||
|
|
74ad52e0f5 | ||
|
|
42e2a1c040 | ||
|
|
f532214e1a | ||
|
|
e9f0bed2d2 | ||
|
|
00b92a61b4 | ||
|
|
41de644945 | ||
|
|
fb7c3a3cdc | ||
|
|
9daa433c44 | ||
|
|
177eb186a5 | ||
|
|
d848622ace | ||
|
|
81af7a8bc0 | ||
|
|
6856a399c5 | ||
|
|
84f7af8d13 | ||
|
|
2070f8fb08 | ||
|
|
fbf2d39640 | ||
|
|
6b0bae9c3d | ||
|
|
bd197bd219 | ||
|
|
5300cf698d | ||
|
|
f048b895b5 | ||
|
|
fdafcfd7a4 | ||
|
|
79961739e0 | ||
|
|
33865b469c | ||
|
|
502c7f9fc1 | ||
|
|
9c855ef923 | ||
|
|
6ceee7fdb7 | ||
|
|
498b8435bf | ||
|
|
1578e76700 | ||
|
|
fbb59cbc2a | ||
|
|
23c3a63aed | ||
|
|
cacaf29771 | ||
|
|
559397d420 | ||
|
|
a64db777d9 | ||
|
|
aa36129053 | ||
|
|
1aeb917d62 | ||
|
|
486f905d65 | ||
|
|
7f1dc95cfc | ||
|
|
ace9e02133 | ||
|
|
770e816468 | ||
|
|
bbe26ec35b | ||
|
|
c002ea3205 | ||
|
|
0fe03e2eec | ||
|
|
5aa949b42c | ||
|
|
11cab8c4a4 | ||
|
|
7c94bd8b67 | ||
|
|
4f25dfb33c | ||
|
|
b96e5e8121 | ||
|
|
2d1479a64f | ||
|
|
a13b46bd2c | ||
|
|
c4358f20f5 | ||
|
|
fd7700a819 | ||
|
|
48ec21def4 | ||
|
|
4ce7c90577 | ||
|
|
896ede0b88 | ||
|
|
ed05b648d1 | ||
|
|
b6001544f1 | ||
|
|
7607607857 | ||
|
|
0c261ca4cb | ||
|
|
4eb11a05bc | ||
|
|
e616582162 | ||
|
|
7b13bb3bd2 | ||
|
|
b16ca7325e | ||
|
|
81bb7156dc | ||
|
|
54818c51b4 | ||
|
|
9e3c679665 | ||
|
|
637d0b9cfb | ||
|
|
61fe57628f | ||
|
|
8c7c4bee0d | ||
|
|
7e0208f8e8 | ||
|
|
680c6877e4 | ||
|
|
16fa960a0b | ||
|
|
c0b0ef66ff | ||
|
|
3e845b32b6 | ||
|
|
b0a05b5905 | ||
|
|
afc2017e1e | ||
|
|
5a37f15bc7 | ||
|
|
6e97466a86 | ||
|
|
0d96010865 | ||
|
|
fea42aba3b | ||
|
|
065cdfa8c1 | ||
|
|
16d15242c7 | ||
|
|
4d8b74ee41 | ||
|
|
f5bdaf85fd | ||
|
|
9c1b9b2939 | ||
|
|
d6ea531cea | ||
|
|
d19f08cf86 | ||
|
|
e805fbc7f3 | ||
|
|
0db1754603 | ||
|
|
f70ab87952 | ||
|
|
561216320f | ||
|
|
1ae85611bf | ||
|
|
cab66e844b | ||
|
|
d693cfe58d | ||
|
|
86387ef5b8 | ||
|
|
0707e60c26 | ||
|
|
44457665ef | ||
|
|
19cee069ec | ||
|
|
9416305f61 | ||
|
|
7b115caf61 | ||
|
|
7076acc540 | ||
|
|
8a2e2c074b | ||
|
|
e438617241 | ||
|
|
4098827253 | ||
|
|
945fd709d4 | ||
|
|
30927ac6db | ||
|
|
dd96cac3d0 | ||
|
|
b0f5141e7d | ||
|
|
578b372335 | ||
|
|
a871fb971b | ||
|
|
b41bc3bb7d | ||
|
|
af8071bf0e | ||
|
|
fcf2ffa39c | ||
|
|
2505aad1e8 | ||
|
|
5da4f748a3 | ||
|
|
63138e818c | ||
|
|
b3c31d14ad | ||
|
|
57b6c5daa2 | ||
|
|
8ec7542a60 | ||
|
|
d64f1e033b | ||
|
|
6b9ca15dff | ||
|
|
2ca3d152d1 | ||
|
|
5ab0b32705 | ||
|
|
5f82cf9066 | ||
|
|
46570a4152 | ||
|
|
34c3dff137 | ||
|
|
91e0cb5838 | ||
|
|
90a2c0539c | ||
|
|
7f24902fc7 | ||
|
|
81f0a9515f | ||
|
|
7d7b037741 | ||
|
|
37af11d3e1 | ||
|
|
c35025aedb | ||
|
|
70e25b7792 | ||
|
|
d91093cf01 | ||
|
|
8226f6e1b3 | ||
|
|
d023519cd8 | ||
|
|
2de4067b03 | ||
|
|
44009cfd92 | ||
|
|
ddcd7d7dc1 | ||
|
|
3e6c856ee5 | ||
|
|
29218a5311 | ||
|
|
dcca7d6952 | ||
|
|
deaf9cd0db | ||
|
|
38eeb56741 | ||
|
|
9b61700b79 | ||
|
|
c90961408f | ||
|
|
0fac6150c7 | ||
|
|
719fa62752 | ||
|
|
f19fbb66e7 | ||
|
|
8aac439a5b | ||
|
|
1d7f531f6a | ||
|
|
eefbec5358 | ||
|
|
3aaea594f4 | ||
|
|
40610fd98f | ||
|
|
9a1ca386ca | ||
|
|
3a2d85265d | ||
|
|
01c69b9396 | ||
|
|
d61f94c42e | ||
|
|
70d128edab | ||
|
|
a3966a72ad | ||
|
|
5c4d73f34a | ||
|
|
d2b8689f48 | ||
|
|
c9eb856f19 | ||
|
|
7ba19364b9 | ||
|
|
dce27530e0 | ||
|
|
fd8b8b5c29 | ||
|
|
3b626bd0a6 | ||
|
|
37bf87e23e | ||
|
|
93f1ef1aca | ||
|
|
4536f917f6 | ||
|
|
d7d6767361 | ||
|
|
a707c6c988 | ||
|
|
8750c2da55 | ||
|
|
308aff5392 | ||
|
|
a7c63c748a | ||
|
|
8e104114f7 | ||
|
|
bf8d2de176 | ||
|
|
7734e47742 | ||
|
|
913d126203 | ||
|
|
146b9d0cdf | ||
|
|
af1ee922ad | ||
|
|
06074f0490 | ||
|
|
e82f42e51a | ||
|
|
f91252f678 | ||
|
|
625edfe33a | ||
|
|
780c2f55dc | ||
|
|
897312831e | ||
|
|
8746a2646a | ||
|
|
82d6d37fd7 | ||
|
|
aa36235ab1 | ||
|
|
f9667ff2e4 | ||
|
|
59ece2f8a8 | ||
|
|
9c3102392b | ||
|
|
e618992fb5 | ||
|
|
c7b4c14d66 | ||
|
|
aedd1a2577 | ||
|
|
f93e771bab | ||
|
|
bfd5a8f6fa | ||
|
|
935d134bbd | ||
|
|
f9ae9985a4 | ||
|
|
03299bae16 | ||
|
|
fc82c8bcf6 | ||
|
|
3882c57cfc | ||
|
|
d9b0d6c740 | ||
|
|
8204f6d2da | ||
|
|
476aec1916 | ||
|
|
940937a9b7 | ||
|
|
598ac6cbd3 | ||
|
|
109b5e71e2 | ||
|
|
9872f505b6 | ||
|
|
2483b7b6d0 | ||
|
|
56283a1480 | ||
|
|
8a01f30a8d | ||
|
|
d429f181a0 | ||
|
|
ab72e5eb62 | ||
|
|
d77f51a5e2 | ||
|
|
1697dc7f7b | ||
|
|
012b2419e5 | ||
|
|
ef53eb62ae | ||
|
|
2ca3d017bf | ||
|
|
6faff7f780 | ||
|
|
91ec675cbe | ||
|
|
c71af9f23a | ||
|
|
30ab1c2196 | ||
|
|
3810ab4ae9 | ||
|
|
db486360cc | ||
|
|
879b6b3b7e | ||
|
|
3fb8b77f87 | ||
|
|
2824c1a3f8 | ||
|
|
e994edbf0a | ||
|
|
1c7316408d | ||
|
|
0105f1d669 | ||
|
|
32145980f4 | ||
|
|
c62ac4359a | ||
|
|
d002d308c4 | ||
|
|
01361bfcaa | ||
|
|
25a0f4b65a | ||
|
|
65f3dd896c | ||
|
|
25ab474fba | ||
|
|
e19e9bfdc7 | ||
|
|
523b762cac | ||
|
|
3864d54936 | ||
|
|
58e41d99c9 | ||
|
|
1c24c805df | ||
|
|
784803336c | ||
|
|
72daf9ebd2 | ||
|
|
940a6b0577 | ||
|
|
ed9df940af | ||
|
|
d32c6f70a1 | ||
|
|
bd2936b05e | ||
|
|
52e7acb4ee | ||
|
|
1734d11639 | ||
|
|
318a487e10 | ||
|
|
ddd614acd0 | ||
|
|
b1d0554a07 | ||
|
|
254d8619fe | ||
|
|
44af8bb340 | ||
|
|
260ad798ed | ||
|
|
a024dd2278 | ||
|
|
ce1d3d4807 | ||
|
|
4697af6c27 | ||
|
|
247f763c11 | ||
|
|
c8319ed014 | ||
|
|
75d1f719ae | ||
|
|
98a3c89435 | ||
|
|
9fc6a1eb57 | ||
|
|
e6d5e35f29 | ||
|
|
8870564066 | ||
|
|
f23510da3a | ||
|
|
bf99bd402f | ||
|
|
7eb756cae6 | ||
|
|
51c6037f3f | ||
|
|
d2dc99d7a1 | ||
|
|
1299ff0f05 | ||
|
|
b82f8aed2b | ||
|
|
fcbe629e48 | ||
|
|
a198fab204 | ||
|
|
a50b74d042 | ||
|
|
e4a85985c2 | ||
|
|
d6630e05d4 | ||
|
|
8c068d3be7 | ||
|
|
3a240f107c | ||
|
|
6f8469eb0b | ||
|
|
ef47624b9d | ||
|
|
b919f51ecb | ||
|
|
7fec6cbb75 | ||
|
|
dc23df8b3a | ||
|
|
b185de70ca | ||
|
|
d232fa0d14 | ||
|
|
3e845f45f7 | ||
|
|
8b1ff7af2b | ||
|
|
570492cea9 | ||
|
|
8aa17ed097 | ||
|
|
e4d073471c | ||
|
|
ae4d087ad6 | ||
|
|
b1e5e45b43 | ||
|
|
eb6ab49543 | ||
|
|
834f8f7d7e | ||
|
|
43d5e8ff23 | ||
|
|
1d122abb17 | ||
|
|
83070a9c01 | ||
|
|
b308e2a8a0 | ||
|
|
c447b2699c | ||
|
|
43c22b8ed5 | ||
|
|
8c7ca490c8 | ||
|
|
e8da60cb77 | ||
|
|
110ff995f8 | ||
|
|
c8a20f4f57 | ||
|
|
f3ab328d74 | ||
|
|
82f1e1b486 | ||
|
|
4b2058fbe6 | ||
|
|
6f623ae080 | ||
|
|
715375831a | ||
|
|
3e0cc865b0 | ||
|
|
7c462cbb64 | ||
|
|
c5e7b4c8a2 | ||
|
|
a00409e003 | ||
|
|
acfabaae23 | ||
|
|
4dc660a52d | ||
|
|
ee8c36f70b | ||
|
|
fa5eb6a285 | ||
|
|
281b44a240 | ||
|
|
6c03a6fb7a | ||
|
|
8a6a2bef02 | ||
|
|
29e4bb5932 | ||
|
|
70e98dfe78 | ||
|
|
6ab9f4232b | ||
|
|
e98ccdc580 | ||
|
|
e1c1be3e50 | ||
|
|
a547a04258 | ||
|
|
0b90af9ccc | ||
|
|
615185deb9 | ||
|
|
d82eac6175 | ||
|
|
b87a3dd92c | ||
|
|
cc403f2624 | ||
|
|
61c85128e8 | ||
|
|
ac7e19144d | ||
|
|
8fb87b18e8 | ||
|
|
bf089193d4 | ||
|
|
c8b1231322 | ||
|
|
be3f6ea301 | ||
|
|
4330b814a6 | ||
|
|
1cab186403 | ||
|
|
5ee5982e3c | ||
|
|
03ee116ed4 | ||
|
|
f702054ac4 | ||
|
|
4bf0a2fa5d | ||
|
|
a2b86ff5f6 | ||
|
|
1b827bcf76 | ||
|
|
6338fb65d1 | ||
|
|
70b1c67f90 | ||
|
|
eaa55ab068 | ||
|
|
62c23c248f | ||
|
|
25c2cfc84e | ||
|
|
4ac3649c90 | ||
|
|
10b9044aa8 | ||
|
|
eb5f985712 | ||
|
|
62ea993847 | ||
|
|
73fda1fb25 | ||
|
|
919abd3bd3 | ||
|
|
5d3d8eeedc | ||
|
|
de2f247c5f | ||
|
|
d52c12606f | ||
|
|
678949aff2 | ||
|
|
d5b0d82adc | ||
|
|
637f424a70 | ||
|
|
2274cdd343 | ||
|
|
aa34132047 | ||
|
|
54b9ae8cd4 | ||
|
|
c6b7ed0ef3 | ||
|
|
6645d579a2 | ||
|
|
ec07d54ed9 | ||
|
|
2ad4e76bc3 | ||
|
|
7c51cf7588 | ||
|
|
b32007b1ca | ||
|
|
80b887c874 | ||
|
|
ff521eb559 | ||
|
|
e9b1bfd2a0 | ||
|
|
9eb4458c73 | ||
|
|
e9f19b6834 | ||
|
|
fa796b9609 | ||
|
|
e6d33a9e1a | ||
|
|
6f9a5a6009 | ||
|
|
d178ce40b5 | ||
|
|
ddc8ba7105 | ||
|
|
006fa3fa4a | ||
|
|
fd088fdbc8 | ||
|
|
ef1669dd4c | ||
|
|
58ec0e7abb | ||
|
|
f5d141d59f | ||
|
|
b30f44a0a1 | ||
|
|
02b372fa3d | ||
|
|
aab9704d24 | ||
|
|
9b69074352 | ||
|
|
9702118f3f | ||
|
|
ee04b728c9 | ||
|
|
b80a86a366 | ||
|
|
3e7e2000d5 | ||
|
|
9070b77b30 | ||
|
|
99445cc8d6 | ||
|
|
bfc2a8ae34 | ||
|
|
6badcc2887 | ||
|
|
b10b51f839 | ||
|
|
9496999773 | ||
|
|
c77df7228e | ||
|
|
fdc696691a | ||
|
|
ff2d04351f | ||
|
|
45ca7aa348 | ||
|
|
24cbcd3937 | ||
|
|
489aece7c5 | ||
|
|
39a6254ee1 | ||
|
|
0bbab221a2 | ||
|
|
9094a167eb | ||
|
|
39b8358ef5 | ||
|
|
a571b77117 | ||
|
|
fd3f85f2b7 | ||
|
|
55d67e92d3 | ||
|
|
a85d0df668 | ||
|
|
f623de53d4 | ||
|
|
5aaa174b20 | ||
|
|
c273fc451a | ||
|
|
62fa1d532c | ||
|
|
92835a1e10 | ||
|
|
20fbeda124 | ||
|
|
56bc96314a | ||
|
|
688f797acf | ||
|
|
5f9edb4fcc | ||
|
|
f35df5e418 | ||
|
|
a64e13a021 | ||
|
|
294f7f6fae | ||
|
|
746ddf6457 | ||
|
|
373a5f004b | ||
|
|
ad35f85c3a | ||
|
|
0d31bfe3c3 | ||
|
|
bd9947a705 | ||
|
|
d30f7ba5ba | ||
|
|
3ea3ff288b | ||
|
|
4e602fc5e8 | ||
|
|
7099a33dc4 | ||
|
|
ee15b00533 | ||
|
|
d480840353 | ||
|
|
16dd423016 | ||
|
|
fb05cf6280 | ||
|
|
c2eccd23a8 | ||
|
|
ed23268672 | ||
|
|
975473b2e4 | ||
|
|
c0707e8cb5 | ||
|
|
d58edf0614 | ||
|
|
ce1cb3a15a | ||
|
|
c650887c04 | ||
|
|
991a5e695a | ||
|
|
2f18ecb562 | ||
|
|
370b786ed0 | ||
|
|
a092922145 | ||
|
|
ff6d722e44 | ||
|
|
d325203e8b | ||
|
|
c11567f095 | ||
|
|
bd38c7dc49 | ||
|
|
80994ffafb | ||
|
|
a1374d228e | ||
|
|
c491f18ba5 | ||
|
|
c7f86311aa | ||
|
|
6b95cc6a5c | ||
|
|
3ce702fc0d | ||
|
|
6c72fb9689 | ||
|
|
7cca8f1889 | ||
|
|
c92d4982c6 | ||
|
|
5f5885cb18 | ||
|
|
b113e2b729 | ||
|
|
8d05d786ce | ||
|
|
b6c146f123 | ||
|
|
73229f51a1 | ||
|
|
26b3fe67a3 | ||
|
|
01dab1613d | ||
|
|
6f4e1a45d1 | ||
|
|
297eb71ff7 | ||
|
|
b66a40fa41 | ||
|
|
ac1ae71f11 | ||
|
|
00cea06899 | ||
|
|
07926bad1e | ||
|
|
57eb506bdf | ||
|
|
5cc8407077 | ||
|
|
f1eca63b5a | ||
|
|
ac1e636caa | ||
|
|
567ef561c0 | ||
|
|
035738f4c5 | ||
|
|
61bd2967b0 | ||
|
|
2fa856e790 | ||
|
|
a8aa3be08a | ||
|
|
6ae57158b7 | ||
|
|
6fec1958b8 | ||
|
|
efdfc809bc | ||
|
|
e62b1b4999 | ||
|
|
7f10548ecb | ||
|
|
53ca7aef1a | ||
|
|
6aa2f49321 | ||
|
|
e0d07c80ac | ||
|
|
fb5d89e987 | ||
|
|
f84f42d8bd | ||
|
|
c28433688a | ||
|
|
80fe93c6c4 | ||
|
|
3071c079ba | ||
|
|
8f33f1f0c9 | ||
|
|
f31209162a | ||
|
|
c1d76bbd29 | ||
|
|
3507117cff | ||
|
|
efb8ec66a2 | ||
|
|
97cae93bb5 | ||
|
|
16bf6bfc2c | ||
|
|
116ca3cbfe | ||
|
|
713e048db9 | ||
|
|
1f18b6b0da | ||
|
|
5eb53725fb | ||
|
|
dab462de35 | ||
|
|
d895fc6a09 | ||
|
|
1c7fe3fd3e | ||
|
|
8cbe832a67 | ||
|
|
e61a6a0b7f | ||
|
|
cd3b9e1422 | ||
|
|
76b41b693b | ||
|
|
115ce8148a | ||
|
|
1fbad779af | ||
|
|
fa78f7b9b3 | ||
|
|
b4a52e244d | ||
|
|
46ae326781 | ||
|
|
af03b30352 | ||
|
|
2857378c87 | ||
|
|
1723525077 | ||
|
|
ad1625dbb3 | ||
|
|
b585ba7a8b | ||
|
|
cafa301ea8 | ||
|
|
7f739a4bc1 | ||
|
|
59c7140ce6 | ||
|
|
aeaa41fcbe | ||
|
|
223c91b6b9 | ||
|
|
30f9f381cd | ||
|
|
7aed1d83e3 | ||
|
|
5e069033b3 | ||
|
|
a919f5edbc | ||
|
|
325feb5637 | ||
|
|
61bd3978a4 | ||
|
|
56ec0f8911 | ||
|
|
48baed897c | ||
|
|
aa52f3d2b5 | ||
|
|
2eb8b496cd | ||
|
|
178aae3883 | ||
|
|
17a4e546a5 | ||
|
|
46dc3b2d00 | ||
|
|
6dea7c3fec | ||
|
|
425722ef65 | ||
|
|
36f57be6cc | ||
|
|
fadc6032fb | ||
|
|
85f97b86da | ||
|
|
e8ca20a7e2 | ||
|
|
4368e1a5f7 | ||
|
|
a129cc5f95 | ||
|
|
98488e5798 | ||
|
|
b80f520162 | ||
|
|
1d7ca67053 | ||
|
|
58c30a638f | ||
|
|
077e625512 | ||
|
|
c166c87479 | ||
|
|
19a15bedfa | ||
|
|
cf41bef449 | ||
|
|
a27cf28544 | ||
|
|
d9d7508292 | ||
|
|
5847e7d2c2 | ||
|
|
f855426a9f | ||
|
|
18f0d9109e | ||
|
|
a78421d79a | ||
|
|
a49cb0935d | ||
|
|
27a408c9f1 | ||
|
|
682aaafc85 | ||
|
|
636a0b5c4d | ||
|
|
e24d3ca33f | ||
|
|
76ba9e5e35 | ||
|
|
b9e4fcf1a6 | ||
|
|
f821707cc6 | ||
|
|
c151ef1526 | ||
|
|
170e441744 | ||
|
|
be908cdf0e | ||
|
|
0defb639c2 | ||
|
|
3d455d0fc9 | ||
|
|
561c36bfe0 | ||
|
|
f07a8f6c2b | ||
|
|
1c7b97d8db | ||
|
|
ee46722a3d | ||
|
|
4fb4c7c85d | ||
|
|
8526c24c3e | ||
|
|
db72bffcbd | ||
|
|
b3396fa62e | ||
|
|
17d3208cbd | ||
|
|
f9d2e04609 | ||
|
|
a2355e3225 | ||
|
|
c076dcb2c7 | ||
|
|
ae1e2599a8 | ||
|
|
8858deb42f | ||
|
|
e3c1280278 | ||
|
|
8794146df7 | ||
|
|
4ce5d989c5 | ||
|
|
1dc5776cb8 | ||
|
|
43cf0f5cba | ||
|
|
e4a3ec37c5 | ||
|
|
2cef7980ea | ||
|
|
db5ea158c3 | ||
|
|
daacb3ca98 | ||
|
|
888c8c453a | ||
|
|
c9db3df251 | ||
|
|
faf3e887b9 | ||
|
|
8269228b8a | ||
|
|
d9b6f0482a | ||
|
|
b8d8e877d7 | ||
|
|
db6f71c8cb | ||
|
|
9c6533bceb | ||
|
|
0d43006a14 | ||
|
|
8a62a68d6e | ||
|
|
3dc23d906e | ||
|
|
9d5474f352 | ||
|
|
3ae9740336 | ||
|
|
5531dabae4 | ||
|
|
7c28c37d0b | ||
|
|
b6bdca7b89 | ||
|
|
c952178749 | ||
|
|
395dc379ed | ||
|
|
3873028209 | ||
|
|
4a11554fa5 | ||
|
|
2fb97fbc51 | ||
|
|
c12ddf43a2 | ||
|
|
a481b5c6cd | ||
|
|
797418ce1a | ||
|
|
95baa55472 | ||
|
|
b105c436ba | ||
|
|
0d880dda50 | ||
|
|
3f24e73978 | ||
|
|
7eaa5cfb22 | ||
|
|
a66d29ba2e | ||
|
|
c3de2e906b | ||
|
|
30d03d280c | ||
|
|
3971b18b04 | ||
|
|
46c3ed0b0d | ||
|
|
bef704d445 | ||
|
|
6e027811ee | ||
|
|
7f9dbaec2a | ||
|
|
3bbccf6c89 | ||
|
|
528855c5d5 | ||
|
|
78dd449baf | ||
|
|
ada4e786ec | ||
|
|
57094d6cce | ||
|
|
de37ae245d | ||
|
|
e1c4be005f | ||
|
|
df728f6856 | ||
|
|
1445760cc0 | ||
|
|
3c0640ce11 | ||
|
|
9268ef5d2f | ||
|
|
adad98f3e2 | ||
|
|
ea87c48586 | ||
|
|
9c06c9802d | ||
|
|
2686f80502 | ||
|
|
5e50ef4045 | ||
|
|
c4fe71c59f | ||
|
|
860371e698 | ||
|
|
1d0abc4cb9 | ||
|
|
d0f052177e | ||
|
|
429e8d2704 | ||
|
|
464865c091 | ||
|
|
7f5a9763e7 | ||
|
|
da707dda4b | ||
|
|
2205ad1eb8 | ||
|
|
db722badaf | ||
|
|
e6387e124f | ||
|
|
c72e2cb852 | ||
|
|
e35b76d0f4 | ||
|
|
f00c21be91 | ||
|
|
62fa5515e1 | ||
|
|
e6287631aa | ||
|
|
377f5000a5 | ||
|
|
82c041329b | ||
|
|
f1c410deaa | ||
|
|
429553a6df | ||
|
|
2a1b2bf7ac | ||
|
|
bdca32be49 | ||
|
|
95e8ef9fc4 | ||
|
|
a2433956fe | ||
|
|
1fe8bad37b | ||
|
|
78eec3f6c8 | ||
|
|
6d0886423c | ||
|
|
e4f4396a6b | ||
|
|
4336266b7f | ||
|
|
e5617d53ee | ||
|
|
a037fb5487 | ||
|
|
80c1b5722f | ||
|
|
95401fb8c4 | ||
|
|
42dd293aa8 | ||
|
|
f4566ce812 | ||
|
|
cb20acfa52 | ||
|
|
565e957387 | ||
|
|
9fcdb2bffa | ||
|
|
1c72196943 | ||
|
|
845fd7ee03 | ||
|
|
5dc4c32eba | ||
|
|
d58e5f9fc2 | ||
|
|
027e5dd61b | ||
|
|
a60c66ad33 | ||
|
|
51179c083c | ||
|
|
ee8779cc00 | ||
|
|
913e7cdb02 | ||
|
|
46e06f2c9d | ||
|
|
ad06f989b7 | ||
|
|
f5a4922aa3 | ||
|
|
856e3542e8 | ||
|
|
8011bd997c | ||
|
|
f345db49cd | ||
|
|
2896da7f9d | ||
|
|
c314c4cba3 | ||
|
|
686503c83c | ||
|
|
fe3a448231 | ||
|
|
1b2f6f4e3d | ||
|
|
262d6d551b | ||
|
|
4d2d01195a | ||
|
|
a68977d9dd | ||
|
|
952bde0e96 | ||
|
|
93bb1c0c96 | ||
|
|
544f37a75b | ||
|
|
141c613eb9 | ||
|
|
6faa7b4cb6 | ||
|
|
c4f666fdd6 | ||
|
|
e90aa13890 | ||
|
|
cb6850f287 | ||
|
|
58b93a9fa9 | ||
|
|
809fd2f7ff | ||
|
|
5813c5d9d8 | ||
|
|
62139047b3 | ||
|
|
6974c53194 | ||
|
|
3832299463 | ||
|
|
8f51de45d0 | ||
|
|
ea5b9518ad | ||
|
|
d1029af180 | ||
|
|
88c5e28577 | ||
|
|
ce8aee9192 | ||
|
|
08643df831 | ||
|
|
42a115e93b | ||
|
|
9c9391c95b | ||
|
|
82ae513d17 | ||
|
|
c3101e92b4 | ||
|
|
14dca2f984 | ||
|
|
d531a47c70 | ||
|
|
47c2024f67 | ||
|
|
5eb323c662 | ||
|
|
2181c74b3e | ||
|
|
1665c4de22 | ||
|
|
62cb1fb3f1 | ||
|
|
a2bd50ee22 | ||
|
|
61365ff3c2 | ||
|
|
86d9e25f17 | ||
|
|
e0b146039f | ||
|
|
54a299f23c | ||
|
|
24a5ae8122 | ||
|
|
27063c9e45 | ||
|
|
78e40807b1 | ||
|
|
7fef6bde78 | ||
|
|
7f78cf23d7 | ||
|
|
34424dfbc3 | ||
|
|
d51f25ea88 | ||
|
|
c8ea1bcd8c | ||
|
|
036301e34f | ||
|
|
e3022f42ab | ||
|
|
548a597843 | ||
|
|
f6479826ca | ||
|
|
e4551713dc | ||
|
|
18254fd385 | ||
|
|
0dbc9f7a8a | ||
|
|
7e36ffc2b4 | ||
|
|
16dc339b2d | ||
|
|
ae7c35c75d | ||
|
|
5845c079cb | ||
|
|
b6f8fb3354 | ||
|
|
48db3d4aa2 | ||
|
|
702504e1d5 | ||
|
|
619ab8a6ed | ||
|
|
594bcffd7c | ||
|
|
46dfd0bfa0 | ||
|
|
bd523f307b | ||
|
|
b220d979bd | ||
|
|
85a904e173 | ||
|
|
16a467b7d1 | ||
|
|
f4b6deb06a | ||
|
|
4b6ab58048 | ||
|
|
b0a1ac3ee4 | ||
|
|
8e1d22bbb9 | ||
|
|
dc01abf48f | ||
|
|
fa7c6907be | ||
|
|
cbc46ed2e0 | ||
|
|
f0e39e92b7 | ||
|
|
db3117b92e | ||
|
|
3f6339887b | ||
|
|
8057e9d0af | ||
|
|
b08aa2ae1f | ||
|
|
a7a22f39d2 | ||
|
|
6f416ab33f | ||
|
|
17ef0a5ca7 | ||
|
|
643a2be9a2 | ||
|
|
29a47b87cd | ||
|
|
36b5430861 | ||
|
|
4f03015486 | ||
|
|
7b48d41d29 | ||
|
|
2fe1bcbdff | ||
|
|
489483e22a | ||
|
|
89c7e734d4 | ||
|
|
b93f75aade | ||
|
|
157c233ab1 | ||
|
|
72e7445f87 | ||
|
|
ccf7ff82a1 | ||
|
|
8342259054 | ||
|
|
abd5b865e1 | ||
|
|
973afe08c5 | ||
|
|
3201655870 | ||
|
|
c051d28c95 | ||
|
|
ab151cc409 | ||
|
|
3c94068910 | ||
|
|
544ae39a95 | ||
|
|
26c79e6740 | ||
|
|
cb48f73fd9 | ||
|
|
728fa279dd | ||
|
|
3d5ca3313b | ||
|
|
ca080b2dcc | ||
|
|
476eabd0fe | ||
|
|
39ad8347c7 | ||
|
|
294410bfd2 | ||
|
|
92b3d08ea0 | ||
|
|
91b049e3a7 | ||
|
|
5af3a490d4 | ||
|
|
855b03fb34 | ||
|
|
bb55246197 | ||
|
|
395e43a3e6 | ||
|
|
656377875b | ||
|
|
32c5f56be1 | ||
|
|
ea155cd68c | ||
|
|
7016afa6f8 | ||
|
|
cc99d4f93f | ||
|
|
424d805def | ||
|
|
cb9c43c2e7 | ||
|
|
ca5648ed03 | ||
|
|
e0f6121dc9 | ||
|
|
c79f2896da | ||
|
|
a02896333a | ||
|
|
409e658f80 | ||
|
|
0c8bd49908 | ||
|
|
5e150ee16a | ||
|
|
09bbe05f08 | ||
|
|
60965b767c | ||
|
|
64c29d495d | ||
|
|
52238e5d27 | ||
|
|
1a9e11a258 | ||
|
|
14d1726a7f | ||
|
|
5ebaf24c04 | ||
|
|
8bba5ea2b6 | ||
|
|
9e259f02d8 | ||
|
|
ce2a9b0bde | ||
|
|
bfe56579aa | ||
|
|
10bd90ab18 | ||
|
|
cd3a4e0e63 | ||
|
|
8b1057b97d | ||
|
|
d915e97ad2 | ||
|
|
64855cba77 | ||
|
|
9e4c71a3f0 | ||
|
|
4c2b67a5aa | ||
|
|
bde1258c87 | ||
|
|
78edc79fc2 | ||
|
|
9ad121f7d7 | ||
|
|
407e474896 | ||
|
|
c2a74df26d | ||
|
|
4018155899 | ||
|
|
a10f416f15 | ||
|
|
4ac78c5b30 | ||
|
|
838f9f592c | ||
|
|
f13dceaa34 | ||
|
|
ab15d05ffd | ||
|
|
715e6dc8be | ||
|
|
4a98610b67 | ||
|
|
f4a53bee61 | ||
|
|
cb1774a678 | ||
|
|
0543ac33d2 | ||
|
|
e3b5f0cc77 | ||
|
|
74a15cd0a1 | ||
|
|
7c4b6d5b20 | ||
|
|
627a0d6f9e | ||
|
|
464e8b4899 | ||
|
|
b2a89a46ca | ||
|
|
8fcf3f1baa | ||
|
|
405ab20ab2 | ||
|
|
e5bbe3a553 | ||
|
|
5e1c681a8d | ||
|
|
faa4309ece | ||
|
|
79ca1523ef | ||
|
|
8be39a6871 | ||
|
|
cc4bc0c3b0 | ||
|
|
2ef56c0cbb | ||
|
|
8d74b8f133 | ||
|
|
8b446e2de0 | ||
|
|
8882cd9558 | ||
|
|
174b8923dc | ||
|
|
26cd91ceb2 | ||
|
|
3a50ab3b91 | ||
|
|
0a83695c45 | ||
|
|
571fa8c885 | ||
|
|
1901a8535a | ||
|
|
a23ffdcd1f | ||
|
|
970652d81f | ||
|
|
6124cdd806 | ||
|
|
97a87c718a | ||
|
|
f368114b88 | ||
|
|
c4b0bf0ee0 | ||
|
|
fb9acf8da4 | ||
|
|
2dfc8f930e | ||
|
|
68c132b003 | ||
|
|
c2292fdadc | ||
|
|
0e7f00cddd | ||
|
|
0cca9f8152 | ||
|
|
d8a6bf08cb | ||
|
|
6c8216d360 | ||
|
|
f365a43f7b | ||
|
|
af75f4c3a3 | ||
|
|
84e05e9490 | ||
|
|
4cc2a11cc5 | ||
|
|
2565d8155d | ||
|
|
e147efd358 | ||
|
|
7c0f9585e7 | ||
|
|
8bb88f397e | ||
|
|
11f4ff4594 | ||
|
|
29298ef978 | ||
|
|
437fa5c128 | ||
|
|
f104d5c891 | ||
|
|
5803fe18ed | ||
|
|
6cccf09bc1 | ||
|
|
bb22845b54 | ||
|
|
f3233528d6 | ||
|
|
319a971080 | ||
|
|
0a978f07d9 | ||
|
|
4919347095 | ||
|
|
9f943fcaa5 | ||
|
|
7c8d11ca20 | ||
|
|
1d9684e11e | ||
|
|
7e46fb8720 | ||
|
|
3bceafef80 | ||
|
|
5dfd04ad70 | ||
|
|
bd51fe383b | ||
|
|
58d2362390 | ||
|
|
254cf3d9cf | ||
|
|
09bbe650e3 | ||
|
|
552459310c | ||
|
|
137dba1bb4 | ||
|
|
f7f5a3cf50 | ||
|
|
2aeaf330a8 | ||
|
|
bd42eebdcb | ||
|
|
78d55d6c78 | ||
|
|
41ea5e4cc7 | ||
|
|
33a33867b5 | ||
|
|
7b71f32d18 | ||
|
|
c77fae435f | ||
|
|
369a6f1977 | ||
|
|
d5c78f58c0 | ||
|
|
a2d038eb46 | ||
|
|
09ca9b5351 |
1011 changed files with 113632 additions and 1843 deletions
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -39,7 +39,7 @@ If you are using a SDK that isn't the latest release, please update first as it'
|
|||
|
||||
5. **SDK logs** (mandatory)
|
||||
|
||||
Enable debug logs in advanced section of the settings, restart the app, reproduce the issue and then go back to advanced settings, click on "Send logs" and copy/paste the link here.
|
||||
Click on "Share logs" in Help -> Troubleshooting view and copy/paste the link here.
|
||||
|
||||
It's also explained [in the README](https://github.com/BelledonneCommunications/linphone-android#behavior-issue).
|
||||
|
||||
|
|
|
|||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -1,15 +1,14 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
app/debug/
|
||||
app/release/
|
||||
.idea/
|
||||
app/bc-android.keystore
|
||||
.kotlin/
|
||||
|
|
|
|||
35
.gitlab-ci-files/job-android.yml
Normal file
35
.gitlab-ci-files/job-android.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
job-android:
|
||||
|
||||
stage: build
|
||||
tags: [ "docker-android" ]
|
||||
image: gitlab.linphone.org:4567/bc/public/linphone-android/bc-dev-android-36:20260120_trixie_java21_android36_gradle9
|
||||
|
||||
before_script:
|
||||
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then eval $(ssh-agent -s); fi
|
||||
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then echo "$SCP_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null; fi
|
||||
- if ! [ -z ${ANDROID_SETTINGS_GRADLE+x} ]; then echo "$ANDROID_SETTINGS_GRADLE" > settings.gradle.kts; fi
|
||||
- git config --global --add safe.directory /builds/BC/public/linphone-android
|
||||
|
||||
script:
|
||||
- scp -oStrictHostKeyChecking=no $DEPLOY_SERVER:$ANDROID_KEYSTORE_PATH app/
|
||||
- scp -oStrictHostKeyChecking=no $DEPLOY_SERVER:$ANDROID_GOOGLE_SERVICES_PATH app/
|
||||
- echo storePassword=$ANDROID_KEYSTORE_PASSWORD > keystore.properties
|
||||
- echo keyPassword=$ANDROID_KEYSTORE_KEY_PASSWORD >> keystore.properties
|
||||
- echo keyAlias=$ANDROID_KEYSTORE_KEY_ALIAS >> keystore.properties
|
||||
- echo storeFile=$ANDROID_KEYSTORE_FILE >> keystore.properties
|
||||
- ./gradlew app:dependencies | grep org.linphone
|
||||
- ./gradlew clean
|
||||
- ./gradlew assembleDebug
|
||||
- ./gradlew assembleRelease
|
||||
|
||||
artifacts:
|
||||
paths:
|
||||
- ./app/build/outputs/apk/release/linphone-android-release-*.apk
|
||||
when: always
|
||||
expire_in: 1 day
|
||||
|
||||
|
||||
.scheduled-job-android:
|
||||
extends: job-android
|
||||
only:
|
||||
- schedules
|
||||
20
.gitlab-ci-files/job-upload.yml
Normal file
20
.gitlab-ci-files/job-upload.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
job-android-upload:
|
||||
|
||||
stage: deploy
|
||||
tags: [ "docker-deploy" ]
|
||||
|
||||
only:
|
||||
- schedules
|
||||
dependencies:
|
||||
- job-android
|
||||
|
||||
before_script:
|
||||
- if ! [ -z ${SCP_PRIVATE_KEY+x} ] && ! [ -z ${DEPLOY_SERVER_HOST_KEYS+x} ]; then eval $(ssh-agent -s); fi
|
||||
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then echo "$SCP_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null; fi
|
||||
- if ! [ -z ${DEPLOY_SERVER_HOST_KEYS+x} ]; then mkdir -p ~/.ssh && chmod 700 ~/.ssh; fi
|
||||
- if ! [ -z ${DEPLOY_SERVER_HOST_KEYS+x} ]; then echo "$DEPLOY_SERVER_HOST_KEYS" >> ~/.ssh/known_hosts; fi
|
||||
|
||||
script:
|
||||
# Launches rsync in partial mode, which means that we are using a temp_dir in case of a transfer issue
|
||||
# Upon a job relaunch, the files in temp_dir would then be re-used, and deleted if the transfer succeeds
|
||||
- cd app/build/outputs/apk/ && rsync --partial --partial-dir=$CI_PIPELINE_ID_$CI_JOB_NAME ./release/*.apk $DEPLOY_SERVER:$ANDROID_DEPLOY_DIRECTORY
|
||||
19
.gitlab-ci.yml
Normal file
19
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#################################################
|
||||
# Base configuration
|
||||
#################################################
|
||||
|
||||
|
||||
|
||||
#################################################
|
||||
# Platforms to test
|
||||
#################################################
|
||||
|
||||
|
||||
include:
|
||||
- '.gitlab-ci-files/job-android.yml'
|
||||
- '.gitlab-ci-files/job-upload.yml'
|
||||
|
||||
|
||||
stages:
|
||||
- build
|
||||
- deploy
|
||||
123
.idea/codeStyles/Project.xml
generated
Normal file
123
.idea/codeStyles/Project.xml
generated
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<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" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
2
.idea/compiler.xml
generated
2
.idea/compiler.xml
generated
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="17" />
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
19
.idea/gradle.xml
generated
19
.idea/gradle.xml
generated
|
|
@ -1,19 +0,0 @@
|
|||
<?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>
|
||||
6
.idea/kotlinc.xml
generated
6
.idea/kotlinc.xml
generated
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.0-RC" />
|
||||
</component>
|
||||
</project>
|
||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
|
|
@ -1,6 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
|
|
|||
426
CHANGELOG.md
426
CHANGELOG.md
|
|
@ -10,14 +10,436 @@ Group changes to describe their impact on the project, as follows:
|
|||
Fixed for any bug fixes.
|
||||
Security to invite users to upgrade in case of vulnerabilities.
|
||||
|
||||
## [6.1.0] - Unreleased
|
||||
|
||||
### Added
|
||||
- Added the ability to edit/delete chat messages sent less than 24 hours ago.
|
||||
- Added keyboard shortcuts on IncomingCallFragment: Ctrl + Shift + A to answer the call, Ctrl + Shift + D to decline it
|
||||
- Added seeking feature to recordings & media player within app
|
||||
- Added PDF preview in conversation (message bubble & documents list)
|
||||
- Added hover effect when using a mouse (useful for tablets or devices with desktop mode)
|
||||
- Support right click on some items to open bottom sheet/menu
|
||||
- Added toggle speaker action in active call notification
|
||||
- Increased text size for chat messages that only contains emoji(s)
|
||||
- Use user-input to filter participants list after typing "@" in conversation send area
|
||||
- Handle read-only CardDAV address books, disable edit/delete menus for contacts in read-only FriendList
|
||||
- Added swipe/pull to refresh on contacts list of a CardDAV addressbook has been configured to force the synchronization
|
||||
- Show information to user when filtering contacts doesn't show them all and user may have to refine it's search
|
||||
- Show Android notification when an account goes to failed registration state (only when background mode is enabled)
|
||||
- New settings:
|
||||
- one for user to choose whether to sort contacts by first name or last name
|
||||
- one to hide contacts that have neither a SIP address nor a phone number
|
||||
- one to let app auto-answer call with video sending already enabled
|
||||
- one to let edit native contacts Linphone copy in-app instead of opening native addressbook third party app
|
||||
- Added a vu meter for recording & playback volumes (must be enabled in developer settings)
|
||||
- Added support for HDMI audio devices
|
||||
|
||||
### Changed
|
||||
- No longer follow TelecomManager audio endpoint during calls, using our own routing policy
|
||||
- Join a conference using default layout instead of audio only when clicking on a meeting SIP URI
|
||||
- Removing an account will also remove all related data in the local database (auth info, call logs, conversations, meetings, etc...)
|
||||
- Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching both the app default domain & the currently selected account domain
|
||||
- Hide SIP address associated to phone number through presence mecanism in contact details & editor views.
|
||||
- Improved UI on tablets with screen sw600dp and higher, will look more like our desktop app
|
||||
- Improved navigation within app when using a keyboard
|
||||
- Now loading media/documents contents in conversation by chunks (instead of all of them at once)
|
||||
- Simplified audio device name in settings
|
||||
- Reworked some settings (moved calls related ones from advanced settings to advanced calls settings)
|
||||
- Increased shared media preview size in chat
|
||||
- Un-encrypted conversation warning will be more visible for accounts that support end-to-end encrypted conversations
|
||||
- Made numpad buttons larger by changing their shape
|
||||
- All LDAP fields are mandatory now
|
||||
- Improved how Android shortcuts are created
|
||||
- Permission fragment will only show missing ones
|
||||
- Added more info into StartupListener logs
|
||||
- Updated password forgotten procedure, will use online account manager platform
|
||||
|
||||
### Fixed
|
||||
- Copy raw message content instead of modified one when it contains a participant mention ("@username")
|
||||
|
||||
## [6.0.22] - 2026-01-20
|
||||
|
||||
### Changed
|
||||
- Close search bar when opening bottom sheet and vice versa
|
||||
|
||||
### Fixed
|
||||
- Sending a file from another app using Android shortcut not working if conversation was already opened
|
||||
- Trying to workaround an issue where ForegroundService notification isn't displayed in the allowed timeframe, causing an Exception
|
||||
|
||||
## [6.0.21] - 2025-12-16
|
||||
|
||||
### Added
|
||||
- Allow linphone-config: scheme URIs in in-app QR code scanner
|
||||
|
||||
### Changed
|
||||
- Workaround for audio focus & audio manager mode on devices that do not support TelecomManager APIs
|
||||
- Set front camera as default after using back camera when scanning a QR code
|
||||
- Added back largeHeap flag in AndroidManifest.xml
|
||||
|
||||
### Fixed
|
||||
- Fixed call recording indicator not showing local record in progress in case UPDATE isn't answered
|
||||
- Fixed native addressbook reload when a contact is updated in the OS default app
|
||||
- Fixed issue with linphone-config scheme URIs if scheme is followed by "//"
|
||||
- Fixed Job & Company contact field not updated if field content was removed
|
||||
- Fixed local avatar not displayed when calling ourselves
|
||||
- Prevent crashes due to some ActivityNotFound exceptions
|
||||
- Prevent crash due to empty clipboard on some devices
|
||||
|
||||
## [6.0.20] - 2025-11-21
|
||||
|
||||
### Changed
|
||||
- Added shrink resources to release config in gradle
|
||||
|
||||
### Fixed
|
||||
- Remove AuthInfo when configuring a CardDAV friend list if synchronization fails
|
||||
- Added missing toast when starting a group call or meeting if there's an issue
|
||||
- Fixed crash in RecordingPlayerFragment due to used lateinit property before it's initialized
|
||||
|
||||
## [6.0.19] - 2025-10-16
|
||||
|
||||
### Added
|
||||
- Spanish and Slovakian translations thanks to Weblate contributors
|
||||
|
||||
### Changed
|
||||
- SIP addresses domain hidden in Suggestions if it matches the currently selected account SIP identity domain
|
||||
- Start proximity sensor when an incoming call is answered from the notification (disabling screen when device is near)
|
||||
|
||||
### Fixed
|
||||
- Black screen when trying to scan a QR Code right after granting CAMERA permission (only happened on some devices)
|
||||
- Possible crash due to ConcurrentModificationException
|
||||
- Camera preview in conference that was black sometimes after switching layout
|
||||
- Possibly wrong screen sharing participant name in conference
|
||||
- Presence SUBSCRIBE that was only sent for sip.linphone.org accounts
|
||||
- Keyboard suggestions in participant picker textfield
|
||||
- Account labelled as Disabled instead of Disconnected when network isn't reachable
|
||||
- Suggestions generated avatar if username starts with '+'
|
||||
- Two LDAP fields label where swapped
|
||||
|
||||
## [6.0.18] - 2025-09-15
|
||||
|
||||
### Added
|
||||
- Added menu icon next to currently selected account avatar to make the drawer menu easier to understand
|
||||
- Added missing dialpad floating action button in the call transfer fragment
|
||||
|
||||
### Changed
|
||||
- Improved bodyless friendlist presence process when it's received
|
||||
|
||||
### Fixed
|
||||
- Fixed "End-to-end encrypted call" label while in conference, the call may be end-to-end encrypted but only to the conference server, not to all participants
|
||||
- Fixed missing meeting subject when calling the conference SIP URI if the conference info doesn't exist yet
|
||||
- Finish CallActivity if no call is found when trying to answer/decline a call from the IncomingCallFragment
|
||||
- Prevent empty screen when rotating the device and clicking on the empty part next to the list while in landscape and then rotating the device back to portrait
|
||||
|
||||
## [6.0.17] - 2025-09-02
|
||||
|
||||
### Changed
|
||||
- Portuguese translation updated from Weblate (still not complete)
|
||||
|
||||
### Fixed
|
||||
- Vibrator not stopped when call is terminated sometimes (SDK fix)
|
||||
- Chat conversation not visible sometimes (SDK fix)
|
||||
|
||||
## [6.0.16] - 2025-08-25
|
||||
|
||||
## Added
|
||||
- Access to Help/Troubleshooting pages from Assistant
|
||||
|
||||
## Fixed
|
||||
- Some Core methods being called from UI thread causing either a crash or a deadlock sometimes
|
||||
- Scrolling issue when doing a search in a conversation with only one result
|
||||
- Contacts not updated after body less presence notify was received
|
||||
- VFS issue due to encrypted.pref file being backed up by Android OS
|
||||
|
||||
## [6.0.15] - 2025-08-11
|
||||
|
||||
### Fixed
|
||||
- Crash due to changes in SDK triggering fatal error if linphone_core_stop() is called from linphone_core_iterate() loop (which was done when scanning QR code)
|
||||
|
||||
### Changed
|
||||
- Prevent leaving assistant after doing a remote provisioning if there is still no account after it (if there was no account before and no account was provided in downloaded config)
|
||||
|
||||
## [6.0.14] - 2025-08-06
|
||||
|
||||
### Fixed
|
||||
- Fixed ANR due to deadlock caused by method being called from wrong thread
|
||||
- Fixed microphone not always recording audio while app in background or if screen is turned off
|
||||
- Fixed missing favorites in start call / create conversation views
|
||||
- Fixed outgoing call view in full screen
|
||||
- Fixed generated avatar for SIP URIs without username
|
||||
|
||||
## [6.0.13] - 2025-07-31
|
||||
|
||||
### Fixed
|
||||
- Missing favourites if contacts list size exceeds magic search max results setting
|
||||
- Muted call on some devices due to Telecom Manager quickly muting/unmuting call
|
||||
- Full screen without video during outgoing early media call if video has been declined by remote end
|
||||
- Removed duplicated week label if "no meeting today" is the first entry for current week
|
||||
- Prevent crash during file export if no app on the device can handle it
|
||||
- Prevent crash that could happen with chat message notification if sender name (or group chat room subject) is empty
|
||||
|
||||
### Changed
|
||||
- Back gesture / navigation button will close the numpad bottom sheet if it's open instead of leaving the page directly
|
||||
- Updated bell and bell_slash icons
|
||||
|
||||
## [6.0.12] - 2025-07-18
|
||||
|
||||
### Fixed
|
||||
- Reactions list in bottom sheet update while opened
|
||||
- Crashes due to late init properties being used before initialized
|
||||
|
||||
## [6.0.11] - 2025-07-11
|
||||
|
||||
### Added
|
||||
- Added toggle in LDAP configuration to allow to quickly enable/disable it
|
||||
|
||||
### Changed
|
||||
- Reduced maximum number of contacts displayed in contacts list, new call/conversation, meeting participant selection etc...
|
||||
- Updated translations
|
||||
|
||||
### Fixed
|
||||
- Calls top bar wrong notification label when going from two calls to one.
|
||||
|
||||
## [6.0.10] - 2025-06-27
|
||||
|
||||
### Added
|
||||
- Added a new top bar alert area for pending file/text sharing.
|
||||
|
||||
### Changed
|
||||
- Reworked in-app top bar alerts, now can show both an account alert and an active call alert.
|
||||
- Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching the default domain and currently default account domain.
|
||||
|
||||
### Fixed
|
||||
- Bluetooth not being used automatically when device is connected during a call.
|
||||
- Call encryption status label stuck in "Waiting for encryption".
|
||||
- Group chat room creation if LIME server URL isn't set.
|
||||
- Participant mention if more than one in the same chat message.
|
||||
- Force default account in call params when starting one.
|
||||
|
||||
## [6.0.9] - 2025-06-06
|
||||
|
||||
### Added
|
||||
- German translation (88% complete)
|
||||
- Link to user guide in Help section
|
||||
- Missing scroll views for help & debug layouts
|
||||
|
||||
### Changed
|
||||
- Prevent port from being set in the SIP identity address in third party account login + remove port (if any) from SIP identity for existing accounts
|
||||
- Show last message timestamp instead of conversation last updated timestamp in conversations list
|
||||
|
||||
### Fixed
|
||||
- Prevent blinking in conversations list when removing message from chat room
|
||||
- Prevent empty (can even lead to crash) display name in call notification (using all identification fields from vCard)
|
||||
|
||||
## [6.0.8] - 2025-05-23
|
||||
|
||||
### Added
|
||||
- Ukrainian & simplified Chinese translations from Weblate
|
||||
- Sliding answer/decline button in incoming call fragment if device is locked (will help prevent calls from being unintentionally picked up or hung up while the device is being removed from a pocket)
|
||||
|
||||
### Changed
|
||||
- Show files with square design when more than one (as it is for media files)
|
||||
- Outgoing chat bubbles will now display the sent file size (as it is for received messages)
|
||||
|
||||
### Fixed
|
||||
- Fixed issue with bluetooth hearing aids
|
||||
- Fixed audio call being answered on speakerphone
|
||||
- Fixed events related to joined/left conversation being briefly visible sometimes for 1-1 conversations
|
||||
- Fixed files/media grid in chat bubble using more than 3 columns in landscape
|
||||
- Fixed logs upload server URL setting
|
||||
|
||||
## [6.0.7] - 2025-05-16
|
||||
|
||||
### Added
|
||||
- CS, NL and RU translations from Weblate
|
||||
|
||||
### Changed
|
||||
- Improved find contact performances
|
||||
- Make sure speaker audio device is used for playing the ringtone during early media
|
||||
- Reworked bottom navigation bar in portrait and unread count indicators
|
||||
- No longer delete conversations when deleting account (for now); causes user to leave group which is an issue when using multiple devices
|
||||
|
||||
### Fixed
|
||||
- Fixed no default account after remote provisioning
|
||||
- Prevent lists from refreshing too many times when using LDAP or remote CardDAV contact directories
|
||||
- Fixed black miniatures in conference if bundle mode is disabled in account params
|
||||
- Fixed long press on a chat message containing a SIP URI triggering call
|
||||
- Disable IMDN bottom sheet for incoming messages in groups instead of showing it empty
|
||||
- Refresh conversations list after clearing conversation history
|
||||
- Fixed another race condition issue related to foreground call service
|
||||
|
||||
## [6.0.6] - 2025-05-02
|
||||
|
||||
### Added
|
||||
- Added recover phone account when clicking on "Forgotten password" in the assistant
|
||||
- Improved message when contacts list is empty depending on the currently selected filter and added a button to open the filter popup menu for users that didn't notice the icon on the top right corner of the screen when contacts list is empty and "SIP contacts only" filter is set.
|
||||
- Added "Logs collection sharing server URL" setting in developper area
|
||||
- Added "Disable sending logs to Crashlytics" advanced setting.
|
||||
|
||||
### Changed
|
||||
- Improved VFS message in confirmation dialog
|
||||
- Moved "Print logs in logcat" and "File sharing server URL" settings to developper area
|
||||
|
||||
### Fixed
|
||||
- Fixed crash when opening a password protected PDF
|
||||
- Fixed chat room lookup while in 1-1 call, using SDK method for getting chat room from conference
|
||||
- Fixed newly created contact not being visible in contacts list without reloading it
|
||||
- Fixed missing event icon for group conversations
|
||||
- Another attempts at preventing crashes due to In-Call service not being started as foreground before being stopped
|
||||
|
||||
## [6.0.5] - 2025-04-18
|
||||
|
||||
### Changed
|
||||
- When calling a SIP URI that looks like a phone number in the username and an IP in the domain, replace the domain with the one of the currently selected account to workaround issue with PBXs using IPs instead of domains in From header
|
||||
- Improved account creation page UI when push notifications aren't available
|
||||
- Improved called account display on incoming call screen when more than one account configured
|
||||
- Updated telecom package from beta to release candidate
|
||||
|
||||
### Fixed
|
||||
- Fixed transfer call view numpad button starting a new call instead of forwarding the current one
|
||||
- Fixed incoming call not displayed in call history depending on how the From & To headers are formatted (SDK fix)
|
||||
- Fixed crashes related to foreground service not being started
|
||||
- Fixed crash due to lateinit property not being initialized before used
|
||||
|
||||
## [6.0.4] - 2025-04-11
|
||||
|
||||
### Changed
|
||||
- Third party SIP accounts push notifications will be disabled and setting will be hidden unless if list of supported domains (to prevent issues, specifically when used with UDP transport protocol causing bigger packets getting lost)
|
||||
|
||||
### Fixed
|
||||
- Prevent refresh of views due to contacts changes to happen to frequently at startup
|
||||
- Prevent crash in Help view if app is built without Firebase
|
||||
|
||||
## [6.0.3] - 2025-04-04
|
||||
|
||||
### Added
|
||||
- Show alert when default account is disabled
|
||||
- Refesh list details when going back from background after one hour or more (when keep app alive using service is enabled)
|
||||
- Click to copy SIP URI in call history shortcut
|
||||
- Added developper settings, must click 8 times on version (in Help) to make it appear (E2E encryption for meetings & group calls setting was moved there)
|
||||
- Circular indicator while search is in progress in contacts lists
|
||||
|
||||
### Changed
|
||||
- Force some default values on notifications channels
|
||||
- Contacts list filter is now applied to new call / conversation & other contact pickers
|
||||
- Attach file icon stays visible while typing message in conversation instead of emoji picker icon
|
||||
|
||||
### Fixed
|
||||
- No default account being selected if the default one is removed
|
||||
- Navigation bar turning orange when opening search bar
|
||||
- Incoming call showed as video even if video is disabled locally
|
||||
- Concurrent modification crash in Contacts loader
|
||||
- Meetings list not properly sorted when CCMP is used
|
||||
- POST_NOTIFICATIONS permission check on old Android devices
|
||||
|
||||
## [6.0.2] - 2025-03-28
|
||||
|
||||
### Added
|
||||
- Show on top bar if FULL_SCREEN_INTENT permission isn't granted, clicking on it sends to the matching settings so user can fix it easily, without it incoming call screen won't be displayed if screen is off
|
||||
- Ring during incoming early media call setting added back
|
||||
- Added a floating action button to open dialpad during outgoing early media call
|
||||
|
||||
### Changed
|
||||
- Delete all related call history / conversations / meetings when removing an account
|
||||
- Delay / use a separated thread for heavy contacts related tasks to ensure call is correctly handled and foreground service is started quickly enough
|
||||
- Newly created account in app will be kept disabled until SMS code validation is done
|
||||
- Keep app alive foreground service notification no shows a content message to ease clicking on it to open the app & workaround a crash on some devices
|
||||
- Automatically show dialpad setting will now also work on new / transfer call while in call as well
|
||||
|
||||
### Fixed
|
||||
- Improved POST_NOTIFICATIONS permission check on Android 13 and newer, should prevent crashes
|
||||
- Fixed contact lookup if phone number starts by "00" instead of "+"
|
||||
- Fixed "delete all call history" sometimes not removing all call logs
|
||||
- Fixed LDAP / remote CardDAV contacts sometimes not displayed in contacts list when doing a search
|
||||
- Fixed issue where contact filter could be set to only show sip.linphone.org contacts even when third party account was being selected
|
||||
- Fixed sometimes wrong displayed SIP URI in detailed call history
|
||||
- Fixed invisible meeting icon in status bar
|
||||
- Fixed missed call count indicator behavior with some third party providers
|
||||
- Prevent today indicator & meeting icon in bottom nav bar from blinking / briefly appearing
|
||||
- Fixed bottom nav bar sometimes being hidden
|
||||
- Fixed missing share logs server URL when migrating from 5.2 if that value was removed back then
|
||||
- Other crashes fixed
|
||||
|
||||
## [6.0.1] - 2025-03-21
|
||||
|
||||
### Added
|
||||
- Start at boot & auto answer settings added back
|
||||
- Interface setting to have dialpad automatically opened in start call view
|
||||
- Replace "+" by "00" and do not apply prefix for calls & chat account settings
|
||||
- Setting to let user choose whether to record calls using MKV or SMFF format (the later allows to record H265/AV1 video but is a proprietary file format that can't be read outside of Linphone)
|
||||
|
||||
### Changed
|
||||
- Reverted the way of playing incoming call ringone (you may have to configure your own ringtone again), was causing various issues depending on devices/firmwares
|
||||
- Show all call history entries if only one account is configured (workaround for missing history for now until a proper fix will be done in SDK)
|
||||
|
||||
### Fixed
|
||||
- Issue preventing bluetooth Hearing Aids from working properly (and fixed earpiece/hearing aids icon)
|
||||
- Prevent Qr Code scanner to use static picture camera
|
||||
- Prevent user from connecting the same account multiple times
|
||||
- Quit menu visibility not updated when changing Keep Alive setting
|
||||
- Participant selection in group when typing "@"
|
||||
- Recordings order has been reversed to have newest ones at top
|
||||
- Improved message when network is not reachable due to "Wifi only mode" being enabled
|
||||
- Various crash & bug fixes
|
||||
|
||||
## [6.0.0] - 2025-03-11
|
||||
|
||||
6.0.0 release is a complete rework of Linphone Android, with a fully redesigned UI, so it is impossible to list everything here.
|
||||
|
||||
### Changed
|
||||
- Separated threads: Contrary to previous versions, our SDK is now running in it's own thread, meaning it won't freeze the UI anymore in case of heavy work, thus reducing the number of ANR and greatly increasing the fluidity of the app.
|
||||
- Asymmetrical video : you no longer need to send your own camera feed to receive the one from the remote end of the call, and vice versa.
|
||||
- Improved multi account: you'll only see history, conversations, meetings etc... related to currently selected account, and you can switch the default account in two clicks.
|
||||
- Call transfer: Blind & Attended call transfer have been merged into one: during a call, if you initiate a transfer action, either pick another call to do the attended transfer or select a contact from the list (you can input a SIP URI not already in the suggestions list) to start a blind transfer.
|
||||
- User can only send up to 12 files in a single chat message.
|
||||
- IMDNs are now only sent to the message sender, preventing huge traffic in large groups, and thus the delivery status icon for received messages is now hidden in groups (as it was in 1-1 conversations).
|
||||
- Settings: a lot of them are gone, the one that are still there have been reworked to increase user friendliness.
|
||||
- Default screen (between contacts, call history, conversations & meetings list) will change depending on where you were when the app was paused or killed, and you will return to that last visited screen on the next startup.
|
||||
- Gradle files have been migrated from Groovy to Kotlin DSL, and dependencies are now in a separated file (libs.versions.toml).
|
||||
- Account creation no longer allows you to use your phone number as username, but it is still required to provide it to receive activation code by SMS.
|
||||
- Minimum supported Android OS version is now 9 (API level 28).
|
||||
- Telecom Manager support is now based on androidx.core.core-telecom package.
|
||||
- Some settings have changed name and/or section in linphonerc file.
|
||||
|
||||
### Added
|
||||
- Contacts trust: contacts for which all devices have been validated through a ZRTP call with SAS exchange are now highlighted with a blue circle (and with a red one in case of mistrust). That trust is now handled at contact level (instead of conversation level in previous versions).
|
||||
- Media & documents exchanged in a conversation can be easily found through a dedicated screen.
|
||||
- A brand new chat message search feature has been added to conversations.
|
||||
- You can now react to a chat message using any emoji.
|
||||
- If next message is also a voice recording, playback will automatically start after the currently playing one ends.
|
||||
- Chat while in call: a shortcut to a conversation screen with the remote.
|
||||
- Chat while in a conference: if the conference has a text stream enabled, you can chat with the other participants of the conference while it lasts. At the end, you'll find the messages history in the call history (and not in the list of conversations).
|
||||
- Auto export of media to native gallery even when auto download is enabled (but still not if VFS is enabled nor for ephemeral messages).
|
||||
- Save / export document & media from ephemeral messages will be disabled, and secure policy that prevents screenshots will be enforced in file viewer even if the setting is disabled.
|
||||
- Notification showing upload/download of files shared through chat will let user know the progress and keep the app alive during that process.
|
||||
- Screen sharing in conference: only desktop app starting with 6.0 version is able to start it, but on mobiles you'll be able to see it.
|
||||
- You can choose whatever ringtone you'd like for incoming calls (in Android notification channel settings).
|
||||
- Security focus: security & trust is more visible than ever, and unsecure conversations & calls are even more visible than before.
|
||||
- CardDAV: you can configure as many CardDAV servers you want to synchronize you contacts in Linphone (in addition or in replacement of native addressbook import).
|
||||
- OpenID: when used with a SSO compliant SIP server (such as Flexisip), we support single-sign-on login.
|
||||
- MWI support: display and allow to call your voicemail when you have new messages (if supported by your VoIP provider and properly configured in your account params).
|
||||
- CCMP support: if you configure a CCMP server URL in your accounts params, it will be used when scheduling meetings & to fetch list of meetings you've organized/been invited to.
|
||||
- Devices list: check on which device your sip.linphone.org account is connected and the last connection date & time (like on subscribe.linphone.org).
|
||||
- Protobuf dependency to allow logging native crashes stack traces at next app startup.
|
||||
- Android 15 startup listener, allowing us to log type of start (cold, warm, etc...) and some other useful info.
|
||||
- Dialer & in-call numpad show letters under the digit.
|
||||
|
||||
### Removed
|
||||
- Dialer: the previous home screen (dialer) has been removed, you'll find it as an input option in the new start call screen.
|
||||
- Peer-to-peer: a SIP account (sip.linphone.org or other) is now required.
|
||||
- Contacts: we no longer add contacts created in-app in the native addressbook (WRITE_CONTACTS permission was removed), but we still import them if you grant us the READ_CONTACTS permission.
|
||||
|
||||
### Fixed
|
||||
- No longer trying to play vocal messages & call recordings using bluetooth when connected to an Android Auto car, causing playback issues.
|
||||
- AAudio driver no longer causes delay when switching between devices (SDK fix).
|
||||
|
||||
## [5.2.5] - 2024-05-03
|
||||
|
||||
## Changed
|
||||
### Changed
|
||||
- Updated translations
|
||||
|
||||
## [5.2.4] - 2024-04-22
|
||||
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Active speaker video hidden when you are the first one to join a meeting
|
||||
- Show camera icon instead of microphone for incoming video calls
|
||||
- SIP URI parsing from native contact due to international prefix being applied when it shouldn't
|
||||
|
|
|
|||
55
README.md
55
README.md
|
|
@ -1,11 +1,12 @@
|
|||
|
||||
[](https://gitlab.linphone.org/BC/public/linphone-android/commits/master) [](https://weblate.linphone.org/engage/linphone/?utm_source=widget)
|
||||
[](https://gitlab.linphone.org/BC/public/linphone-android/commits/master)
|
||||
[](https://weblate.linphone.org/engage/linphone/)
|
||||
|
||||
Linphone is an open source softphone for voice and video over IP calling and instant messaging.
|
||||
|
||||
It is fully SIP-based, for all calling, presence and IM features.
|
||||
|
||||
General description is available from [linphone web site](https://www.linphone.org/technical-corner/linphone).
|
||||
General description is available from [linphone web site](https://linphone.org).
|
||||
|
||||
### How to get it
|
||||
|
||||
|
|
@ -21,11 +22,11 @@ Linphone is dual licensed, and is available either :
|
|||
|
||||
- under a [GNU/GPLv3 license](https://www.gnu.org/licenses/gpl-3.0.en.html), for free (open source). Please make sure that you understand and agree with the terms of this license before using it (see LICENSE file for details).
|
||||
|
||||
- under a proprietary license, for a fee, to be used in closed source applications. Contact [Belledonne Communications](https://www.linphone.org/contact) for any question about costs and services.
|
||||
- under a proprietary license, for a fee, to be used in closed source applications. Contact [Belledonne Communications](https://linphone.org/contact) for any question about costs and services.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Supported features and RFCs : https://www.linphone.org/technical-corner/linphone/features
|
||||
- Supported features and RFCs : https://www.linphone.org/linphone-softphone/#linphone-fonctionnalites
|
||||
|
||||
- Linphone public wiki : https://wiki.linphone.org/xwiki/wiki/public/view/Linphone/
|
||||
|
||||
|
|
@ -33,16 +34,11 @@ Linphone is dual licensed, and is available either :
|
|||
|
||||
# What's new
|
||||
|
||||
App has been totally rewritten in Kotlin using modern components such as Navigation, Data Binding, View Models, coroutines, etc...
|
||||
Check the [CHANGELOG](./CHANGELOG.md) file for a more detailed list.
|
||||
The first linphone-android release that will be based on this will be 4.5.0, using 5.0.0 SDK.
|
||||
6.0.0 release is a completely new version, designed with UX/UI experts and marks a turning point in design, features, and user experience. The improvements make this version smoother and simpler for both developers and users.
|
||||
|
||||
We're also taking a fresh start regarding translations so less languages will be available for a while.
|
||||
If you want to contribute, you are welcome to do so, check the [Translations](#Translations) section below.
|
||||
You can take a look at the [CHANGELOG.md](CHANGELOG.md) file for a non-exhaustive list of changes of this new version and of the newly added features, the most exciting ones being the improved fluidity, a real multi-accounts support and asymmetrical video in calls.
|
||||
|
||||
org.linphone.legacy flavor (old java wrapper if you didn't migrate your app code to the new one yet) is no longer supported starting 5.0.0 SDK.
|
||||
|
||||
The sample project has been removed, we now recommend you to take a look at our [tutorials](https://gitlab.linphone.org/BC/public/tutorials/-/tree/master/android/kotlin).
|
||||
This release only works on Android OS 9.0 and newer.
|
||||
|
||||
# Building the app
|
||||
|
||||
|
|
@ -97,6 +93,8 @@ LinphoneSdkBuildDir=/home/<username>/linphone-sdk/build/
|
|||
|
||||
## Known issues
|
||||
|
||||
- If you have the following build issue `AAPT: error: resource drawable/linphone_logo_tinted (aka org.linphone:drawable/linphone_logo_tinted) not found`, delete the `app/src/main/res/xml/contacts.xml` file (you can do it simply with `git clean -f` command) and start the build again.
|
||||
|
||||
- If you encounter the `couldn't find "libc++_shared.so"` crash when the app starts, simply clean the project in Android Studio (under Build menu) and build again.
|
||||
Also check you have built the SDK for the right CPU architecture using the `-DLINPHONESDK_ANDROID_ARCHS=armv7,arm64,x86,x86_64` cmake parameter.
|
||||
|
||||
|
|
@ -106,13 +104,9 @@ Also check you have built the SDK for the right CPU architecture using the `-DLI
|
|||
|
||||
### Behavior issue
|
||||
|
||||
When submitting an issue on our [Github repository](https://github.com/BelledonneCommunications/linphone-android), please follow the template and attach the matching library logs:
|
||||
When submitting an issue on our [Github repository](https://github.com/BelledonneCommunications/linphone-android), please follow the template and attach the matching library logs.
|
||||
|
||||
1. To enable them, go to Settings -> Advanced and toggle `Debug Mode`. If they are already enabled, clear them first using the `Reset logs` button on the About page.
|
||||
|
||||
2. Then restart the app, reproduce the issue and upload the logs using the `Send logs` button on the advanced settings page.
|
||||
|
||||
3. Finally paste the link to the uploaded logs (link is already in the clipboard after a successful upload).
|
||||
Starting 6.0.0 release, logs are always enabled and stored locally on the device, you can clear them/upload them securely on our server for sharing by going into the Help → Troubleshooting page.
|
||||
|
||||
### Native crash
|
||||
|
||||
|
|
@ -120,16 +114,13 @@ First of all, to be able to get a symbolized stack trace, you need the debug ver
|
|||
|
||||
If you haven't built the SDK locally (see [building a local SDK](#BuildingalocalSDK)), here's how to get them:
|
||||
|
||||
1. Go to our [maven repository](https://download.linphone.org/maven_repository/org/linphone/linphone-sdk-android-debug/), in the linphone-android-debug directory.
|
||||
1. Go to our [maven repository](https://download.linphone.org/maven_repository/org/linphone/linphone-sdk-android/) and find the directory that matches the version of our SDK that crashed.
|
||||
|
||||
2. Download the AAR file with **the exact same version** as the AAR that was used to generate the crash's stacktrace.
|
||||
2. Download the linphone-sdk-android-<version>-libs-debug.zip archive.
|
||||
|
||||
3. Extract the AAR somewhere on your computer (it's a simple ZIP file even it's doesn't have the extension). Libraries are stored inside the ```jni``` folder (a directory for each architectured built, usually ```arm64-v8a, armeabi-v7a, x86_64 and x86```).
|
||||
|
||||
4. To get consistent with locally built SDK, rename the ```jni``` directory into ```libs-debug```.
|
||||
3. Extract the symbolized libraries somewhere on your computer, it will create a ```libs-debug``` directory.
|
||||
|
||||
Now you need the ```ndk-stack``` tool and possibly ```adb logcat```.
|
||||
|
||||
If your computer isn't used for Android development, you can download those tools from [Google website](https://developer.android.com/studio#downloads), in the ```Command line tools only``` section.
|
||||
|
||||
Once you have the debug libraries and the proper tools installed, you can use the ```ndk-stack``` tool to symbolize your stacktrace. Note that you also need to know the architecture (armv7, arm64, x86, etc...) of the libraries that were used.
|
||||
|
|
@ -144,22 +135,22 @@ adb logcat -d | ndk-stack -sym ./libs-debug/`adb shell getprop ro.product.cpu.ab
|
|||
```
|
||||
Warning: This command won't print anything until you reproduce the crash!
|
||||
|
||||
Starting [NDK r29](https://github.com/android/ndk/wiki/Changelog-r29) you will be able to directly use the ```libs-debug.zip``` file in ```ndk-stack -sym``` argument.
|
||||
|
||||
## Create an APK with a different package name
|
||||
|
||||
Simply edit the app/build.gradle file and change the value of the ```packageName``` variable.
|
||||
Simply edit the ```app/build.gradle.kts``` file and change the value of the ```packageName``` variable.
|
||||
The next build will automatically use this value everywhere thanks to ```manifestPlaceholders``` feature of gradle and Android.
|
||||
|
||||
You may have already noticed that the app installed by Android Studio has ```org.linphone.debug``` package name.
|
||||
If you build the app as release, the package name will be ```org.linphone```.
|
||||
We no longer build the debug flavor with a different package name, but if you still want that behavior you only have to change the value of ```useDifferentPackageNameForDebugBuild``` to ```true```. When enabled, app built and installed by Android studio will have ```org.linphone.debug``` package name instead of ```org.linphone```.
|
||||
|
||||
If you encounter
|
||||
```
|
||||
Execution failed for task ':app:processDebugGoogleServices'.
|
||||
> No matching client found for package name 'your package name'
|
||||
```
|
||||
|
||||
error when building, make sure you have replaced ```app/google-services.json``` file by yours (containing your package name).
|
||||
If you don't have such file, remove ours.
|
||||
error when building, make sure you have replaced the ```app/google-services.json``` file by yours (containing your package name).
|
||||
If you don't have such file because you don't rely on Firebase Cloud Messaging features nor Crashlytics, delete the file instead.
|
||||
|
||||
## Firebase push notifications
|
||||
|
||||
|
|
@ -177,8 +168,8 @@ We no longer use transifex for the translation process, instead we have deployed
|
|||
|
||||
Due to the full app rewrite we can't re-use previous translations, so we'll be very happy if you want to contribute.
|
||||
|
||||
<a href="https://weblate.linphone.org/engage/linphone/?utm_source=widget">
|
||||
<img src="https://weblate.linphone.org/widgets/linphone/-/linphone-android/multi-auto.svg" alt="Translation status" />
|
||||
<a href="https://weblate.linphone.org/engage/linphone/">
|
||||
<img src="https://weblate.linphone.org/widget/linphone/linphone-android-6-0/multi-auto.svg" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
# CONTRIBUTIONS
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
id 'org.jlleitschuh.gradle.ktlint' version '11.3.1'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
static def getPackageName() {
|
||||
return "org.linphone"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'org.linphone'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId getPackageName()
|
||||
minSdk 27
|
||||
targetSdk 34
|
||||
versionCode 60000
|
||||
versionName "6.0.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
resValue "string", "file_provider", getPackageName() + ".fileprovider"
|
||||
}
|
||||
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
|
||||
resValue "string", "file_provider", getPackageName() + ".fileprovider"
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = 17
|
||||
targetCompatibility = 17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0-alpha02'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.6.0'
|
||||
implementation 'androidx.gridlayout:gridlayout:1.0.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.1-rc01'
|
||||
implementation 'androidx.core:core-ktx:+'
|
||||
implementation 'androidx.core:core-ktx:+'
|
||||
|
||||
def nav_version = "2.6.0"
|
||||
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
|
||||
def emoji_version = "1.4.0-beta05"
|
||||
implementation "androidx.emoji2:emoji2:$emoji_version"
|
||||
implementation "androidx.emoji2:emoji2-emojipicker:$emoji_version"
|
||||
|
||||
// https://github.com/material-components/material-components-android/blob/master/LICENSE Apache v2.0
|
||||
implementation 'com.google.android.material:material:1.9.0'
|
||||
|
||||
// https://github.com/google/flexbox-layout/blob/main/LICENSE Apache v2.0
|
||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||
|
||||
// https://github.com/coil-kt/coil/blob/main/LICENSE.txt Apache v2.0
|
||||
def coil_version = "2.4.0"
|
||||
implementation("io.coil-kt:coil:$coil_version")
|
||||
implementation("io.coil-kt:coil-gif:$coil_version")
|
||||
implementation("io.coil-kt:coil-svg:$coil_version")
|
||||
implementation("io.coil-kt:coil-video:$coil_version")
|
||||
|
||||
implementation platform('com.google.firebase:firebase-bom:30.3.2')
|
||||
implementation 'com.google.firebase:firebase-messaging'
|
||||
|
||||
implementation 'org.linphone:linphone-sdk-android:5.3+'
|
||||
}
|
||||
|
||||
ktlint {
|
||||
android = true
|
||||
ignoreFailures = true
|
||||
}
|
||||
|
||||
project.tasks['preBuild'].dependsOn 'ktlintFormat'
|
||||
320
app/build.gradle.kts
Normal file
320
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import com.android.build.gradle.internal.tasks.factory.dependsOn
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
|
||||
import com.google.gms.googleservices.GoogleServicesPlugin
|
||||
import java.io.BufferedReader
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.kapt)
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||
alias(libs.plugins.navigation)
|
||||
alias(libs.plugins.crashlytics)
|
||||
}
|
||||
|
||||
val packageName = "org.linphone"
|
||||
val useDifferentPackageNameForDebugBuild = false
|
||||
|
||||
val sdkPath = providers.gradleProperty("LinphoneSdkBuildDir").get()
|
||||
val googleServices = File(projectDir.absolutePath + "/google-services.json")
|
||||
val linphoneLibs = File("$sdkPath/libs/")
|
||||
val linphoneDebugLibs = File("$sdkPath/libs-debug/")
|
||||
val firebaseCloudMessagingAvailable = googleServices.exists()
|
||||
val crashlyticsAvailable = googleServices.exists() && linphoneLibs.exists() && linphoneDebugLibs.exists()
|
||||
|
||||
if (firebaseCloudMessagingAvailable) {
|
||||
println("google-services.json found, enabling CloudMessaging feature")
|
||||
apply<GoogleServicesPlugin>()
|
||||
} else {
|
||||
println("google-services.json not found, disabling CloudMessaging feature")
|
||||
}
|
||||
|
||||
var gitVersion = "6.1.0-alpha"
|
||||
var gitBranch = ""
|
||||
try {
|
||||
val gitDescribe = ProcessBuilder()
|
||||
.command("git", "describe", "--abbrev=0")
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
.trim()
|
||||
println("Git describe: $gitDescribe")
|
||||
|
||||
val gitCommitsCount = ProcessBuilder()
|
||||
.command("git", "rev-list", "$gitDescribe..HEAD", "--count")
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
.trim()
|
||||
println("Git commits count: $gitCommitsCount")
|
||||
|
||||
val gitCommitHash = ProcessBuilder()
|
||||
.command("git", "rev-parse", "--short", "HEAD")
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
.trim()
|
||||
println("Git commit hash: $gitCommitHash")
|
||||
|
||||
gitBranch = ProcessBuilder()
|
||||
.command("git", "name-rev", "--name-only", "HEAD")
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
.trim()
|
||||
println("Git branch name: $gitBranch")
|
||||
|
||||
gitVersion =
|
||||
if (gitCommitsCount.toInt() == 0) {
|
||||
gitDescribe
|
||||
} else {
|
||||
"$gitDescribe.$gitCommitsCount+$gitCommitHash"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Git not found [$e], using $gitVersion")
|
||||
}
|
||||
println("Computed git version: $gitVersion")
|
||||
|
||||
configurations {
|
||||
implementation { isCanBeResolved = true }
|
||||
}
|
||||
|
||||
tasks.register("linphoneSdkSource") {
|
||||
doLast {
|
||||
configurations.implementation.get().incoming.resolutionResult.allComponents.forEach {
|
||||
if (it.id.displayName.contains("linphone-sdk-android")) {
|
||||
println("Linphone SDK used is ${it.moduleVersion?.version}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
project.tasks.preBuild.dependsOn("linphoneSdkSource")
|
||||
|
||||
android {
|
||||
namespace = "org.linphone"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = packageName
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 601002 // 6.01.002
|
||||
versionName = "6.1.0-alpha"
|
||||
|
||||
manifestPlaceholders["appAuthRedirectScheme"] = packageName
|
||||
|
||||
ndk {
|
||||
//noinspection ChromeOsAbiSupport
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
output.outputFileName = "linphone-android-${variant.buildType.name}-${project.version}.apk"
|
||||
}
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val keyStorePath = keystoreProperties["storeFile"] as String
|
||||
val keyStore = project.file(keyStorePath)
|
||||
if (keyStore.exists()) {
|
||||
storeFile = keyStore
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
println("Signing config release is using keystore [$storeFile]")
|
||||
} else {
|
||||
println("Keystore [$storeFile] doesn't exists!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
if (useDifferentPackageNameForDebugBuild) {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
|
||||
val appVersion = gitVersion
|
||||
val appBranch = gitBranch
|
||||
println("Debug flavor app version is [$appVersion], app branch is [$appBranch]")
|
||||
resValue("string", "linphone_app_version", appVersion)
|
||||
resValue("string", "linphone_app_branch", appBranch)
|
||||
if (useDifferentPackageNameForDebugBuild) {
|
||||
resValue("string", "file_provider", "$packageName.debug.fileprovider")
|
||||
} else {
|
||||
resValue("string", "file_provider", "$packageName.fileprovider")
|
||||
}
|
||||
resValue("string", "linphone_openid_callback_scheme", packageName)
|
||||
|
||||
if (crashlyticsAvailable) {
|
||||
val path = File("$sdkPath/libs-debug/").toString()
|
||||
configure<CrashlyticsExtension> {
|
||||
nativeSymbolUploadEnabled = true
|
||||
unstrippedNativeLibsDir = path
|
||||
}
|
||||
}
|
||||
buildConfigField("Boolean", "CRASHLYTICS_ENABLED", crashlyticsAvailable.toString())
|
||||
}
|
||||
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
|
||||
val appVersion = gitVersion
|
||||
val appBranch = gitBranch
|
||||
println("Release flavor app version is [$appVersion], app branch is [$appBranch]")
|
||||
resValue("string", "linphone_app_version", appVersion)
|
||||
resValue("string", "linphone_app_branch", appBranch)
|
||||
resValue("string", "file_provider", "$packageName.fileprovider")
|
||||
resValue("string", "linphone_openid_callback_scheme", packageName)
|
||||
|
||||
if (crashlyticsAvailable) {
|
||||
val path = File("$sdkPath/libs-debug/").toString()
|
||||
configure<CrashlyticsExtension> {
|
||||
nativeSymbolUploadEnabled = true
|
||||
unstrippedNativeLibsDir = path
|
||||
}
|
||||
}
|
||||
buildConfigField("Boolean", "CRASHLYTICS_ENABLED", crashlyticsAvailable.toString())
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
buildConfig = true
|
||||
resValues = true
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.annotations)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.constraint.layout)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.splashscreen)
|
||||
implementation(libs.androidx.telecom)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.slidingpanelayout)
|
||||
implementation(libs.androidx.window)
|
||||
implementation(libs.androidx.gridlayout)
|
||||
implementation(libs.androidx.security.crypto.ktx)
|
||||
implementation(libs.androidx.navigation.fragment.ktx)
|
||||
implementation(libs.androidx.navigation.ui.ktx)
|
||||
implementation(libs.androidx.emoji2)
|
||||
implementation(libs.androidx.car)
|
||||
|
||||
// https://github.com/google/flexbox-layout/blob/main/LICENSE Apache v2.0
|
||||
implementation(libs.google.flexbox)
|
||||
// https://github.com/material-components/material-components-android/blob/master/LICENSE Apache v2.0
|
||||
implementation(libs.google.material)
|
||||
// To be able to parse native crash tombstone and print them with SDK logs the next time the app will start
|
||||
implementation(libs.google.protobuf)
|
||||
|
||||
implementation(platform(libs.google.firebase.bom))
|
||||
implementation(libs.google.firebase.messaging)
|
||||
implementation(libs.google.firebase.crashlytics)
|
||||
|
||||
// https://github.com/coil-kt/coil/blob/main/LICENSE.txt Apache v2.0
|
||||
implementation(libs.coil)
|
||||
implementation(libs.coil.gif)
|
||||
implementation(libs.coil.svg)
|
||||
implementation(libs.coil.video)
|
||||
// https://github.com/tommybuonomo/dotsindicator/blob/master/LICENSE Apache v2.0
|
||||
implementation(libs.dots.indicator)
|
||||
// https://github.com/Baseflow/PhotoView/blob/master/LICENSE Apache v2.0
|
||||
implementation(libs.photoview)
|
||||
// https://github.com/openid/AppAuth-Android/blob/master/LICENSE Apache v2.0
|
||||
implementation(libs.openid.appauth)
|
||||
|
||||
implementation(libs.linphone)
|
||||
}
|
||||
|
||||
configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
|
||||
android.set(true)
|
||||
ignoreFailures.set(true)
|
||||
additionalEditorconfig.set(
|
||||
mapOf(
|
||||
"max_line_length" to "120",
|
||||
"ktlint_standard_max-line-length" to "disabled",
|
||||
"ktlint_standard_function-signature" to "disabled",
|
||||
"ktlint_standard_no-blank-line-before-rbrace" to "disabled",
|
||||
"ktlint_standard_no-empty-class-body" to "disabled",
|
||||
"ktlint_standard_annotation-spacing" to "disabled",
|
||||
"ktlint_standard_class-signature" to "disabled",
|
||||
"ktlint_standard_function-expression-body" to "disabled",
|
||||
"ktlint_standard_function-type-modifier-spacing" to "disabled",
|
||||
"ktlint_standard_if-else-wrapping" to "disabled",
|
||||
"ktlint_standard_argument-list-wrapping" to "disabled",
|
||||
"ktlint_standard_trailing-comma-on-call-site" to "disabled",
|
||||
"ktlint_standard_trailing-comma-on-declaration-site" to "disabled",
|
||||
"ktlint_standard_no-empty-first-line-in-class-body" to "disabled",
|
||||
"ktlint_standard_no-empty-first-line-in-method-block" to "disabled",
|
||||
"ktlint_standard_no-trailing-spaces" to "disabled",
|
||||
"ktlint_standard_no-blank-line-in-list" to "disabled",
|
||||
"ktlint_standard_no-multi-spaces" to "disabled",
|
||||
"ktlint_standard_try-catch-finally-spacing" to "disabled",
|
||||
"ktlint_standard_block-comment-initial-star-alignment" to "disabled",
|
||||
"ktlint_standard_spacing-between-declarations-with-comments" to "disabled",
|
||||
"ktlint_standard_no-consecutive-comments" to "disabled",
|
||||
"ktlint_standard_multiline-expression-wrapping" to "disabled",
|
||||
"ktlint_standard_parameter-list-wrapping" to "disabled",
|
||||
"ktlint_standard_comment-wrapping" to "disabled",
|
||||
"ktlint_standard_discouraged-comment-location" to "disabled",
|
||||
"ktlint_standard_string-template-indent" to "disabled",
|
||||
"ktlint_standard_parameter-list-spacing" to "disabled",
|
||||
"ktlint_standard_statement-wrapping" to "disabled",
|
||||
"ktlint_standard_import-ordering" to "disabled",
|
||||
"ktlint_standard_paren-spacing" to "disabled",
|
||||
"ktlint_standard_curly-spacing" to "disabled",
|
||||
"ktlint_standard_indent" to "disabled",
|
||||
)
|
||||
)
|
||||
}
|
||||
project.tasks.preBuild.dependsOn("ktlintFormat")
|
||||
|
||||
if (crashlyticsAvailable) {
|
||||
afterEvaluate {
|
||||
tasks.getByName("assembleDebug").finalizedBy(
|
||||
tasks.getByName("uploadCrashlyticsSymbolFileDebug"),
|
||||
)
|
||||
tasks.getByName("packageDebug").finalizedBy(
|
||||
tasks.getByName("uploadCrashlyticsSymbolFileDebug"),
|
||||
)
|
||||
tasks.getByName("assembleRelease").finalizedBy(
|
||||
tasks.getByName("uploadCrashlyticsSymbolFileRelease"),
|
||||
)
|
||||
tasks.getByName("packageRelease").finalizedBy(
|
||||
tasks.getByName("uploadCrashlyticsSymbolFileRelease"),
|
||||
)
|
||||
}
|
||||
}
|
||||
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
|
|
@ -1,6 +1,6 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
|
|
|||
|
|
@ -2,6 +2,46 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- To be able to display contacts list & match calling/called numbers -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
|
||||
<!-- Starting Android 13 we need to ask notification permission -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- Needed for full screen intent in incoming call notifications -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<!-- To vibrate while receiving an incoming call -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Needed for foreground service
|
||||
(https://developer.android.com/guide/components/foreground-services) -->
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<!-- Needed for Android 14
|
||||
https://developer.android.com/about/versions/14/behavior-changes-14#fgs-types -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<!-- Required for foreground service started when a push is being received,
|
||||
without it app won't be able to access network if data saver is ON (for example) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- Needed to keep a permanent foreground service and keep app alive to be able to receive
|
||||
messages & calls for third party accounts for which push notifications aren't available,
|
||||
and starting Android 15 dataSync is limited to 6 hours per day
|
||||
and can't be used with RECEIVE_BOOT_COMPLETED intent either -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!-- Needed for auto start at boot if keep alive service is enabled -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".LinphoneApplication"
|
||||
android:allowBackup="true"
|
||||
|
|
@ -11,21 +51,164 @@
|
|||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:theme="@style/Theme.Linphone"
|
||||
tools:targetApi="34">
|
||||
android:appCategory="social"
|
||||
android:largeHeap="true"
|
||||
tools:targetApi="35">
|
||||
|
||||
<!-- Required for chat message & call notifications to be displayed in Android auto -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
<!--<meta-data
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="1"/>-->
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:theme="@style/AppSplashScreenTheme"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
android:launchMode="singleTask">
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_LOCUS" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.DIAL" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.intent.action.CALL" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="tel" />
|
||||
<data android:scheme="sip" />
|
||||
<data android:scheme="callto" />
|
||||
<data android:scheme="sips" />
|
||||
<data android:scheme="sip-linphone" />
|
||||
<data android:scheme="sips-linphone" />
|
||||
<data android:scheme="linphone-sip" />
|
||||
<data android:scheme="linphone-sips" />
|
||||
<data android:scheme="linphone-config" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.welcome.WelcomeActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.assistant.AssistantActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.fileviewer.MediaViewerActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.fileviewer.FileViewerActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.call.CallActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.LinphoneInCall"
|
||||
android:launchMode="singleInstance"
|
||||
android:turnScreenOn="true"
|
||||
android:showWhenLocked="true"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true" />
|
||||
|
||||
<!-- Services -->
|
||||
|
||||
<service
|
||||
android:name=".core.CoreInCallService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="phoneCall|camera|microphone"
|
||||
android:stopWithTask="false"
|
||||
android:label="@string/app_name" />
|
||||
|
||||
<service
|
||||
android:name=".core.CorePushService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:stopWithTask="false"
|
||||
android:label="@string/app_name" />
|
||||
|
||||
<service
|
||||
android:name=".core.CoreFileTransferService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:stopWithTask="false"
|
||||
android:label="@string/app_name" />
|
||||
|
||||
<service android:name="org.linphone.core.tools.firebase.FirebaseMessaging"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".core.CoreKeepAliveThirdPartyAccountsService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:stopWithTask="false"
|
||||
android:label="@string/app_name">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Needed to keep app alive to be able to receive messages and calls from third party SIP servers for which push notifications aren't available." />
|
||||
</service>
|
||||
|
||||
<!--<service
|
||||
android:name=".telecom.auto.AndroidAutoService"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.car.app.CarAppService"/>
|
||||
<category android:name="androidx.car.app.category.CALLING"/>
|
||||
</intent-filter>
|
||||
</service>-->
|
||||
|
||||
<!-- Receivers -->
|
||||
|
||||
<receiver android:name=".core.CorePushReceiver"
|
||||
|
|
@ -35,6 +218,19 @@
|
|||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".notifications.NotificationBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver android:name=".core.BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Providers -->
|
||||
|
||||
<provider
|
||||
|
|
|
|||
|
|
@ -21,22 +21,15 @@
|
|||
<entry name="cpim_in_basic_chat_rooms_enabled" overwrite="true">1</entry>
|
||||
<entry name="rtp_bundle" overwrite="true">1</entry>
|
||||
<entry name="lime_server_url" overwrite="true">https://lime.linphone.org/lime-server/lime-server.php</entry>
|
||||
<entry name="lime_algo" overwrite="true">c25519</entry>
|
||||
<entry name="supported" overwrite="true"></entry>
|
||||
</section>
|
||||
<section name="nat_policy_default_values">
|
||||
<entry name="stun_server" overwrite="true">stun.linphone.org</entry>
|
||||
<entry name="protocols" overwrite="true">stun,ice</entry>
|
||||
</section>
|
||||
<section name="net">
|
||||
<entry name="friendlist_subscription_enabled" overwrite="true">1</entry>
|
||||
</section>
|
||||
<section name="assistant">
|
||||
<entry name="domain" overwrite="true">sip.linphone.org</entry>
|
||||
<entry name="algorithm" overwrite="true">SHA-256</entry>
|
||||
<entry name="password_max_length" overwrite="true">-1</entry>
|
||||
<entry name="password_min_length" overwrite="true">1</entry>
|
||||
<entry name="username_length" overwrite="true">-1</entry>
|
||||
<entry name="username_max_length" overwrite="true">64</entry>
|
||||
<entry name="username_min_length" overwrite="true">1</entry>
|
||||
<entry name="username_regex" overwrite="true">^[a-z0-9+_.\-]*$</entry>
|
||||
<section name="sip">
|
||||
<entry name="media_encryption" overwrite="true">srtp</entry>
|
||||
<entry name="media_encryption_mandatory">1</entry>
|
||||
</section>
|
||||
</config>
|
||||
|
|
|
|||
|
|
@ -21,22 +21,18 @@
|
|||
<entry name="cpim_in_basic_chat_rooms_enabled" overwrite="true">0</entry>
|
||||
<entry name="rtp_bundle" overwrite="true">0</entry>
|
||||
<entry name="lime_server_url" overwrite="true"></entry>
|
||||
<entry name="lime_algo" overwrite="true"></entry>
|
||||
<entry name="supported" overwrite="true">outbound</entry>
|
||||
</section>
|
||||
<section name="nat_policy_default_values">
|
||||
<entry name="stun_server" overwrite="true"></entry>
|
||||
<entry name="protocols" overwrite="true"></entry>
|
||||
<entry name="stun_server" overwrite="true">stun.linphone.org</entry>
|
||||
<entry name="protocols" overwrite="true">stun,ice</entry>
|
||||
</section>
|
||||
<section name="net">
|
||||
<entry name="friendlist_subscription_enabled" overwrite="true">0</entry>
|
||||
<section name="sip">
|
||||
<entry name="media_encryption">srtp</entry>
|
||||
<entry name="media_encryption_mandatory" overwrite="true">0</entry>
|
||||
</section>
|
||||
<section name="assistant">
|
||||
<entry name="domain" overwrite="true"></entry>
|
||||
<entry name="algorithm" overwrite="true">MD5</entry>
|
||||
<entry name="password_max_length" overwrite="true">-1</entry>
|
||||
<entry name="password_min_length" overwrite="true">0</entry>
|
||||
<entry name="username_length" overwrite="true">-1</entry>
|
||||
<entry name="username_max_length" overwrite="true">128</entry>
|
||||
<entry name="username_min_length" overwrite="true">1</entry>
|
||||
<entry name="username_regex" overwrite="true">^[a-zA-Z0-9+_.\-]*$</entry>
|
||||
<section name="ui">
|
||||
<entry name="automatically_show_dialpad" overwrite="true">1</entry>
|
||||
</section>
|
||||
</config>
|
||||
|
|
@ -11,6 +11,9 @@ sip_tcp_port=-1
|
|||
sip_tls_port=-1
|
||||
media_encryption=none
|
||||
update_presence_model_timestamp_before_publish_expires_refresh=1
|
||||
use_rfc2833=1
|
||||
use_info=1
|
||||
rls_uri=sips:rls@sip.linphone.org
|
||||
|
||||
[net]
|
||||
#Because dynamic bitrate adaption can increase bitrate, we must allow "no limit"
|
||||
|
|
@ -19,26 +22,38 @@ upload_bw=0
|
|||
|
||||
[video]
|
||||
size=vga
|
||||
automatically_accept=1
|
||||
automatically_initiate=0
|
||||
automatically_accept_direction=2 #receive only
|
||||
|
||||
[app]
|
||||
tunnel=disabled
|
||||
auto_start=1
|
||||
record_aware=1
|
||||
auto_download_incoming_voice_recordings=1
|
||||
auto_download_incoming_icalendars=1
|
||||
|
||||
[tunnel]
|
||||
host=
|
||||
port=443
|
||||
|
||||
[misc]
|
||||
log_collection_upload_server_url=https://www.linphone.org:444/lft.php
|
||||
file_transfer_server_url=https://www.linphone.org:444/lft.php
|
||||
version_check_url_root=https://www.linphone.org/releases
|
||||
log_collection_upload_server_url=https://files.linphone.org/http-file-transfer-server/hft.php
|
||||
file_transfer_server_url=https://files.linphone.org/http-file-transfer-server/hft.php
|
||||
version_check_url_root=https://download.linphone.org/releases
|
||||
max_calls=10
|
||||
history_max_size=100
|
||||
conference_layout=1
|
||||
hide_empty_chat_rooms=1
|
||||
|
||||
[in-app-purchase]
|
||||
server_url=https://subscribe.linphone.org:444/inapp.php
|
||||
purchasable_items_ids=test_account_subscription
|
||||
[fec]
|
||||
fec_enabled=1
|
||||
|
||||
[magic_search]
|
||||
return_empty_friends=1
|
||||
|
||||
[chat]
|
||||
imdn_to_everybody_threshold=1
|
||||
|
||||
[ui]
|
||||
contacts_filter=sip.linphone.org
|
||||
|
||||
## End of default rc
|
||||
|
|
|
|||
|
|
@ -18,13 +18,19 @@ auto_net_state_mon=1
|
|||
auto_answer_replacing_calls=1
|
||||
ping_with_options=0
|
||||
use_cpim=1
|
||||
zrtp_key_agreements_suites=MS_ZRTP_KEY_AGREEMENT_K255_KYB512
|
||||
zrtp_key_agreements_suites=MS_ZRTP_KEY_AGREEMENT_K255_MLK512,MS_ZRTP_KEY_AGREEMENT_K255_KYB512
|
||||
chat_messages_aggregation_delay=1000
|
||||
chat_messages_aggregation=1
|
||||
update_presence_model_timestamp_before_publish_expires_refresh=1
|
||||
|
||||
[sound]
|
||||
#remove this property for any application that is not Linphone public version itself
|
||||
ec_calibrator_cool_tones=1
|
||||
disable_ringing=0
|
||||
|
||||
[audio]
|
||||
android_disable_audio_focus_requests=1
|
||||
android_monitor_audio_devices=0
|
||||
|
||||
[video]
|
||||
displaytype=MSAndroidTextureDisplay
|
||||
|
|
@ -36,63 +42,18 @@ enable_basic_to_client_group_chat_room_migration=0
|
|||
enable_simple_group_chat_message_state=0
|
||||
aggregate_imdn=1
|
||||
notify_each_friend_individually_when_presence_received=0
|
||||
store_friends=0
|
||||
|
||||
[app]
|
||||
activation_code_length=4
|
||||
prefer_basic_chat_room=1
|
||||
record_aware=1
|
||||
|
||||
[account_creator]
|
||||
backend=1
|
||||
# 1 means FlexiAPI, 0 is XMLRPC
|
||||
url=https://subscribe.linphone.org/api/
|
||||
# replace above URL by https://staging-subscribe.linphone.org/api/ for testing
|
||||
|
||||
[lime]
|
||||
lime_update_threshold=86400
|
||||
|
||||
[nat_policy_0]
|
||||
ref=HQ0DK7mVDOPAY3i
|
||||
stun_server=stun.linphone.org
|
||||
protocols=stun,ice
|
||||
|
||||
[proxy_0]
|
||||
reg_proxy=<sip:sip.linphone.org;transport=tls>
|
||||
reg_route=sip:sip.linphone.org;transport=tls
|
||||
reg_identity="Sylvain Berfini" <sip:sylvain@sip.linphone.org>
|
||||
realm=sip.linphone.org
|
||||
contact_parameters=message-expires=604800
|
||||
quality_reporting_collector=sip:voip-metrics@sip.linphone.org;transport=tls
|
||||
push_parameters=pn-silent=1;pn-timeout=0;
|
||||
quality_reporting_enabled=1
|
||||
quality_reporting_interval=180
|
||||
reg_expires=600
|
||||
reg_sendregister=1
|
||||
publish=1
|
||||
avpf=1
|
||||
avpf_rr_interval=1
|
||||
dial_escape_plus=0
|
||||
dial_prefix=33
|
||||
use_dial_prefix_for_calls_and_chats=1
|
||||
privacy=32768
|
||||
push_notification_allowed=1
|
||||
remote_push_notification_allowed=0
|
||||
cpim_in_basic_chat_rooms_enabled=1
|
||||
idkey=proxy_config_WSik0NIEZbTW4fM
|
||||
publish_expires=120
|
||||
nat_policy_ref=-ulaFqPYu2HOZ90
|
||||
conference_factory_uri=sip:conference-factory@sip.linphone.org
|
||||
audio_video_conference_factory_uri=sip:videoconference-factory@sip.linphone.org
|
||||
rtp_bundle=1
|
||||
rtp_bundle_assumption=0
|
||||
lime_server_url=https://lime.linphone.org/lime-server/lime-server.php
|
||||
|
||||
[auth_info_0]
|
||||
username=sylvain
|
||||
ha1=4028ae98f54e8ffd1ab5c90985a6e89752aa0228b3e14b7ffcc40e42b4787e56
|
||||
realm=sip.linphone.org
|
||||
domain=sip.linphone.org
|
||||
algorithm=SHA-256
|
||||
available_algorithms=SHA-256
|
||||
[alerts]
|
||||
alerts_enabled=1
|
||||
|
||||
## End of factory rc
|
||||
|
|
|
|||
|
|
@ -21,24 +21,34 @@ package org.linphone
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.decode.VideoFrameDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import androidx.annotation.MainThread
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.imageLoader
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.crossfade
|
||||
import coil3.svg.SvgDecoder
|
||||
import coil3.video.VideoFrameDecoder
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import org.linphone.compatibility.Compatibility
|
||||
import org.linphone.core.CoreContext
|
||||
import org.linphone.core.CorePreferences
|
||||
import org.linphone.core.Factory
|
||||
import org.linphone.core.LogCollectionState
|
||||
import org.linphone.core.LogLevel
|
||||
import org.linphone.mediastream.Version
|
||||
import org.linphone.core.VFS
|
||||
import org.linphone.core.tools.Log
|
||||
|
||||
class LinphoneApplication : Application(), ImageLoaderFactory {
|
||||
@MainThread
|
||||
class LinphoneApplication : Application(), SingletonImageLoader.Factory {
|
||||
companion object {
|
||||
private const val TAG = "[Linphone Application]"
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
lateinit var corePreferences: CorePreferences
|
||||
|
||||
|
|
@ -50,6 +60,13 @@ class LinphoneApplication : Application(), ImageLoaderFactory {
|
|||
super.onCreate()
|
||||
val context = applicationContext
|
||||
|
||||
val powerManager = context.getSystemService(POWER_SERVICE) as PowerManager
|
||||
val wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"Linphone:AppCreation"
|
||||
)
|
||||
wakeLock.acquire(20000L) // 20 seconds
|
||||
|
||||
Factory.instance().setLogCollectionPath(context.filesDir.absolutePath)
|
||||
Factory.instance().enableLogCollection(LogCollectionState.Enabled)
|
||||
// For VFS
|
||||
|
|
@ -57,6 +74,11 @@ class LinphoneApplication : Application(), ImageLoaderFactory {
|
|||
|
||||
corePreferences = CorePreferences(context)
|
||||
corePreferences.copyAssetsFromPackage()
|
||||
|
||||
if (VFS.isEnabled(context)) {
|
||||
VFS.setup(context)
|
||||
}
|
||||
|
||||
val config = Factory.instance().createConfigWithFactory(
|
||||
corePreferences.configPath,
|
||||
corePreferences.factoryConfigPath
|
||||
|
|
@ -65,37 +87,77 @@ class LinphoneApplication : Application(), ImageLoaderFactory {
|
|||
|
||||
val appName = context.getString(R.string.app_name)
|
||||
Factory.instance().setLoggerDomain(appName)
|
||||
Factory.instance().enableLogcatLogs(true)
|
||||
Factory.instance().loggingService.setLogLevel(LogLevel.Message)
|
||||
Factory.instance().enableLogcatLogs(corePreferences.printLogsInLogcat)
|
||||
|
||||
Log.i("$TAG Report Core preferences initialized")
|
||||
Compatibility.setupAppStartupListener(context)
|
||||
|
||||
coreContext = CoreContext(context)
|
||||
coreContext.start()
|
||||
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
wakeLock.release()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
override fun onTrimMemory(level: Int) {
|
||||
Log.w("$TAG onTrimMemory called with level [${trimLevelToString(level)}]($level) !")
|
||||
when (level) {
|
||||
TRIM_MEMORY_RUNNING_LOW,
|
||||
TRIM_MEMORY_RUNNING_CRITICAL,
|
||||
TRIM_MEMORY_MODERATE,
|
||||
TRIM_MEMORY_COMPLETE -> {
|
||||
Log.i("$TAG Memory trim required, clearing imageLoader memory cache")
|
||||
imageLoader.memoryCache?.clear()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
super.onTrimMemory(level)
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: Context): ImageLoader {
|
||||
// When VFS is enabled, prevent Coil from keeping plain version of files on disk
|
||||
val diskCachePolicy = if (VFS.isEnabled(applicationContext)) {
|
||||
CachePolicy.DISABLED
|
||||
} else {
|
||||
CachePolicy.ENABLED
|
||||
}
|
||||
|
||||
return ImageLoader.Builder(this)
|
||||
.crossfade(false)
|
||||
.components {
|
||||
add(VideoFrameDecoder.Factory())
|
||||
// add(GifDecoder.Factory) // Do not add it, GIFs are properly rendered without it and adding it breaks resizing...
|
||||
add(SvgDecoder.Factory())
|
||||
if (Version.sdkAboveOrEqual(Version.API28_PIE_90)) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
val cache = cacheDir.resolve("image_cache")
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("image_cache"))
|
||||
.directory(cache)
|
||||
.maxSizePercent(0.02)
|
||||
.build()
|
||||
}
|
||||
.networkCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCachePolicy(diskCachePolicy)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun trimLevelToString(level: Int): String {
|
||||
return when (level) {
|
||||
TRIM_MEMORY_UI_HIDDEN -> "Hidden UI"
|
||||
TRIM_MEMORY_RUNNING_MODERATE -> "Moderate (Running)"
|
||||
TRIM_MEMORY_RUNNING_LOW -> "Low"
|
||||
TRIM_MEMORY_RUNNING_CRITICAL -> "Critical"
|
||||
TRIM_MEMORY_BACKGROUND -> "Background"
|
||||
TRIM_MEMORY_MODERATE -> "Moderate"
|
||||
TRIM_MEMORY_COMPLETE -> "Complete"
|
||||
else -> level.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Notification
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.Service
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.AppUtils
|
||||
|
||||
class Api28Compatibility {
|
||||
companion object {
|
||||
private const val TAG = "[API 28 Compatibility]"
|
||||
|
||||
fun startServiceForeground(service: Service, id: Int, notification: Notification) {
|
||||
try {
|
||||
service.startForeground(
|
||||
id,
|
||||
notification
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Can't start service as foreground! $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun enterPipMode(activity: Activity): Boolean {
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(AppUtils.getPipRatio(activity))
|
||||
.build()
|
||||
try {
|
||||
if (!activity.enterPictureInPictureMode(params)) {
|
||||
Log.e("$TAG Failed to enter PiP mode")
|
||||
} else {
|
||||
Log.i("$TAG Entered PiP mode")
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Can't build PiP params: $e")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun getMediaCollectionUri(isImage: Boolean, isVideo: Boolean, isAudio: Boolean): Uri {
|
||||
return when {
|
||||
isImage -> {
|
||||
MediaStore.Images.Media.getContentUri("external")
|
||||
}
|
||||
isVideo -> {
|
||||
MediaStore.Video.Media.getContentUri("external")
|
||||
}
|
||||
isAudio -> {
|
||||
MediaStore.Audio.Media.getContentUri("external")
|
||||
}
|
||||
else -> Uri.EMPTY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.InetAddresses.isNumericAddress
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.view.View
|
||||
import android.view.contentcapture.ContentCaptureContext
|
||||
import android.view.contentcapture.ContentCaptureSession
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
class Api29Compatibility {
|
||||
companion object {
|
||||
fun getMediaCollectionUri(isImage: Boolean, isVideo: Boolean, isAudio: Boolean): Uri {
|
||||
return when {
|
||||
isImage -> {
|
||||
MediaStore.Images.Media.getContentUri(
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
}
|
||||
isVideo -> {
|
||||
MediaStore.Video.Media.getContentUri(
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
}
|
||||
isAudio -> {
|
||||
MediaStore.Audio.Media.getContentUri(
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
}
|
||||
else -> Uri.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
fun extractLocusIdFromIntent(intent: Intent): String? {
|
||||
return intent.getStringExtra(Intent.EXTRA_LOCUS_ID)
|
||||
}
|
||||
|
||||
fun setLocusIdInContentCaptureSession(root: View, conversationId: String) {
|
||||
val session: ContentCaptureSession? = root.contentCaptureSession
|
||||
if (session != null) {
|
||||
session.contentCaptureContext = ContentCaptureContext.forLocusId(conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
fun isIpAddress(string: String): Boolean {
|
||||
return isNumericAddress(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.AppUtils
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
class Api31Compatibility {
|
||||
companion object {
|
||||
private const val TAG = "[API 31 Compatibility]"
|
||||
|
||||
fun enableAutoEnterPiP(activity: Activity, enable: Boolean) {
|
||||
try {
|
||||
activity.setPictureInPictureParams(
|
||||
PictureInPictureParams.Builder()
|
||||
.setAspectRatio(AppUtils.getPipRatio(activity))
|
||||
.setAutoEnterEnabled(enable)
|
||||
.build()
|
||||
)
|
||||
Log.i("$TAG PiP auto enter has been [${if (enable) "enabled" else "disabled"}]")
|
||||
} catch (iae: IllegalArgumentException) {
|
||||
Log.e("$TAG Can't set PiP params: $iae")
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e("$TAG Can't set PiP params: $ise")
|
||||
}
|
||||
}
|
||||
|
||||
fun setBlurRenderEffect(view: View) {
|
||||
val blurEffect = RenderEffect.createBlurEffect(16F, 16F, Shader.TileMode.MIRROR)
|
||||
view.setRenderEffect(blurEffect)
|
||||
}
|
||||
|
||||
fun removeBlurRenderEffect(view: View) {
|
||||
view.setRenderEffect(null)
|
||||
}
|
||||
|
||||
fun forceDarkMode(context: Context) {
|
||||
val uiManager = ContextCompat.getSystemService(context, UiModeManager::class.java)
|
||||
if (uiManager == null) {
|
||||
Log.e("$TAG Failed to get UiModeManager system service!")
|
||||
}
|
||||
uiManager?.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES)
|
||||
}
|
||||
|
||||
fun forceLightMode(context: Context) {
|
||||
val uiManager = ContextCompat.getSystemService(context, UiModeManager::class.java)
|
||||
if (uiManager == null) {
|
||||
Log.e("$TAG Failed to get UiModeManager system service!")
|
||||
}
|
||||
uiManager?.setApplicationNightMode(UiModeManager.MODE_NIGHT_NO)
|
||||
}
|
||||
|
||||
fun setAutoLightDarkMode(context: Context) {
|
||||
val uiManager = ContextCompat.getSystemService(context, UiModeManager::class.java)
|
||||
if (uiManager == null) {
|
||||
Log.e("$TAG Failed to get UiModeManager system service!")
|
||||
}
|
||||
uiManager?.setApplicationNightMode(UiModeManager.MODE_NIGHT_AUTO)
|
||||
}
|
||||
|
||||
fun getRecordingsDirectory(): String {
|
||||
return Environment.DIRECTORY_RECORDINGS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
class Api33Compatibility {
|
||||
companion object {
|
||||
fun getAllRequiredPermissionsArray(): Array<String> {
|
||||
return arrayOf(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
Manifest.permission.CAMERA
|
||||
)
|
||||
}
|
||||
|
||||
fun isPostNotificationsPermissionGranted(context: Context): Boolean {
|
||||
return context.checkSelfPermission(
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun getPendingIntentActivityOptions(): ActivityOptions {
|
||||
val options = ActivityOptions.makeBasic()
|
||||
options.isPendingIntentBackgroundActivityLaunchAllowed = true
|
||||
return options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.linphone.core.tools.Log
|
||||
import androidx.core.net.toUri
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
class Api34Compatibility {
|
||||
companion object {
|
||||
private const val TAG = "[API 34 Compatibility]"
|
||||
|
||||
fun startServiceForeground(
|
||||
service: Service,
|
||||
id: Int,
|
||||
notification: Notification,
|
||||
foregroundServiceType: Int
|
||||
) {
|
||||
try {
|
||||
service.startForeground(
|
||||
id,
|
||||
notification,
|
||||
foregroundServiceType
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Can't start service as foreground! $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun hasFullScreenIntentPermission(context: Context): Boolean {
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java) as NotificationManager
|
||||
// See https://developer.android.com/reference/android/app/NotificationManager#canUseFullScreenIntent%28%29
|
||||
val granted = notificationManager.canUseFullScreenIntent()
|
||||
if (granted) {
|
||||
Log.i("$TAG Full screen intent permission is granted")
|
||||
} else {
|
||||
Log.w("$TAG Full screen intent permission isn't granted yet!")
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
fun requestFullScreenIntentPermission(context: Context) {
|
||||
val intent = Intent()
|
||||
// See https://developer.android.com/reference/android/provider/Settings#ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT
|
||||
intent.action = Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT
|
||||
intent.data = "package:${context.packageName}".toUri()
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
|
||||
Log.i("$TAG Starting ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT")
|
||||
try {
|
||||
context.startActivity(intent, null)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e("$TAG Failed to start intent for granting full screen intent permission: $anfe")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPendingIntent(pendingIntent: PendingIntent, bundle: Bundle) {
|
||||
pendingIntent.send(bundle)
|
||||
}
|
||||
|
||||
fun getPendingIntentActivityOptions(creator: Boolean): ActivityOptions {
|
||||
val options = ActivityOptions.makeBasic()
|
||||
if (creator) {
|
||||
options.pendingIntentCreatorBackgroundActivityStartMode =
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
|
||||
} else {
|
||||
options.pendingIntentBackgroundActivityStartMode =
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.ApplicationStartInfo
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.util.concurrent.Executors
|
||||
import org.linphone.core.tools.Log
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
class Api35Compatibility {
|
||||
companion object {
|
||||
private const val TAG = "[API 35 Compatibility]"
|
||||
|
||||
fun setupAppStartupListener(context: Context) {
|
||||
try {
|
||||
val activityManager = context.getSystemService(ActivityManager::class.java)
|
||||
activityManager.addApplicationStartInfoCompletionListener(
|
||||
Executors.newSingleThreadExecutor()
|
||||
) { info ->
|
||||
Log.i("==== Current startup information dump ====")
|
||||
logAppStartupInfo(info)
|
||||
}
|
||||
|
||||
Log.i("==== Fetching last three startup reasons if available ====")
|
||||
val lastStartupInfo = activityManager.getHistoricalProcessStartReasons(3)
|
||||
for (info in lastStartupInfo) {
|
||||
Log.i("==== Previous startup information dump ====")
|
||||
logAppStartupInfo(info)
|
||||
}
|
||||
} catch (iae: IllegalArgumentException) {
|
||||
Log.e("$TAG Can't add application start info completion listener: $iae")
|
||||
}
|
||||
}
|
||||
|
||||
private fun logAppStartupInfo(info: ApplicationStartInfo) {
|
||||
Log.i("TYPE = ${startupTypeToString(info.startType)}")
|
||||
Log.i("STATE = ${startupStateToString(info.startupState)}")
|
||||
Log.i("REASON = ${startupReasonToString(info.reason)}")
|
||||
Log.i("START COMPONENT = ${startComponentToString(info.launchMode)}")
|
||||
Log.i("INTENT = ${info.intent}")
|
||||
Log.i("FORCE STOPPED = ${if (info.wasForceStopped()) "yes" else "no"}")
|
||||
Log.i("PROCESS NAME = ${info.processName}")
|
||||
Log.i("=========================================")
|
||||
}
|
||||
|
||||
private fun startComponentToString(component: Int): String {
|
||||
return when (component) {
|
||||
ApplicationStartInfo.START_COMPONENT_ACTIVITY -> "Activity"
|
||||
ApplicationStartInfo.START_COMPONENT_BROADCAST -> "Broadcast"
|
||||
ApplicationStartInfo.START_COMPONENT_CONTENT_PROVIDER -> "Content Provider"
|
||||
ApplicationStartInfo.START_COMPONENT_SERVICE -> "Service"
|
||||
ApplicationStartInfo.START_COMPONENT_OTHER -> "Other"
|
||||
else -> "Unexpected ($component)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startupTypeToString(type: Int): String {
|
||||
return when (type) {
|
||||
ApplicationStartInfo.START_TYPE_COLD -> "Cold"
|
||||
ApplicationStartInfo.START_TYPE_HOT -> "Hot"
|
||||
ApplicationStartInfo.START_TYPE_UNSET -> "Unset"
|
||||
ApplicationStartInfo.START_TYPE_WARM -> "Warm"
|
||||
else -> "Unexpected ($type)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startupStateToString(state: Int): String {
|
||||
return when (state) {
|
||||
ApplicationStartInfo.STARTUP_STATE_STARTED -> "Started"
|
||||
ApplicationStartInfo.STARTUP_STATE_ERROR -> "Error"
|
||||
ApplicationStartInfo.STARTUP_STATE_FIRST_FRAME_DRAWN -> "First frame drawn"
|
||||
else -> "Unexpected ($state)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startupReasonToString(reason: Int): String {
|
||||
return when (reason) {
|
||||
ApplicationStartInfo.START_REASON_ALARM -> "Alarm"
|
||||
ApplicationStartInfo.START_REASON_BACKUP -> "Backup"
|
||||
ApplicationStartInfo.START_REASON_BOOT_COMPLETE -> "Boot complete"
|
||||
ApplicationStartInfo.START_REASON_BROADCAST -> "Broadcast"
|
||||
ApplicationStartInfo.START_REASON_CONTENT_PROVIDER -> "Content provider"
|
||||
ApplicationStartInfo.START_REASON_JOB -> "Job"
|
||||
ApplicationStartInfo.START_REASON_LAUNCHER -> "Launcher"
|
||||
ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS -> "Launcher (recents)"
|
||||
ApplicationStartInfo.START_REASON_OTHER -> "Other"
|
||||
ApplicationStartInfo.START_REASON_PUSH -> "Push"
|
||||
ApplicationStartInfo.START_REASON_SERVICE -> "Service"
|
||||
ApplicationStartInfo.START_REASON_START_ACTIVITY -> "Start Activity"
|
||||
else -> "Unexpected ($reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
|
||||
class Api36Compatibility {
|
||||
companion object {
|
||||
private const val TAG = "[API 36 Compatibility]"
|
||||
|
||||
fun getPendingIntentActivityOptions(creator: Boolean): ActivityOptions {
|
||||
val options = ActivityOptions.makeBasic()
|
||||
if (creator) {
|
||||
options.pendingIntentCreatorBackgroundActivityStartMode =
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS
|
||||
} else {
|
||||
options.pendingIntentBackgroundActivityStartMode =
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
}
|
||||
220
app/src/main/java/org/linphone/compatibility/Compatibility.kt
Normal file
220
app/src/main/java/org/linphone/compatibility/Compatibility.kt
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityOptions
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.util.Patterns
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.mediastream.Version
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
class Compatibility {
|
||||
companion object {
|
||||
private const val TAG = "[Compatibility]"
|
||||
|
||||
const val FOREGROUND_SERVICE_TYPE_PHONE_CALL = 4 // ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
||||
const val FOREGROUND_SERVICE_TYPE_CAMERA = 64 // ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
const val FOREGROUND_SERVICE_TYPE_MICROPHONE = 128 // ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
const val FOREGROUND_SERVICE_TYPE_SPECIAL_USE = 1073741824 // ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
|
||||
fun startServiceForeground(
|
||||
service: Service,
|
||||
id: Int,
|
||||
notification: Notification,
|
||||
foregroundServiceType: Int
|
||||
) {
|
||||
if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) {
|
||||
Api34Compatibility.startServiceForeground(
|
||||
service,
|
||||
id,
|
||||
notification,
|
||||
foregroundServiceType
|
||||
)
|
||||
} else {
|
||||
Api28Compatibility.startServiceForeground(service, id, notification)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBlurRenderEffect(view: View) {
|
||||
if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) {
|
||||
Api31Compatibility.setBlurRenderEffect(view)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeBlurRenderEffect(view: View) {
|
||||
if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) {
|
||||
Api31Compatibility.removeBlurRenderEffect(view)
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaCollectionUri(
|
||||
isImage: Boolean = false,
|
||||
isVideo: Boolean = false,
|
||||
isAudio: Boolean = false
|
||||
): Uri {
|
||||
return if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10)) {
|
||||
Api29Compatibility.getMediaCollectionUri(isImage, isVideo, isAudio)
|
||||
} else {
|
||||
Api28Compatibility.getMediaCollectionUri(isImage, isVideo, isAudio)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllRequiredPermissionsArray(): Array<String> {
|
||||
if (Version.sdkAboveOrEqual(Version.API33_ANDROID_13_TIRAMISU)) {
|
||||
return Api33Compatibility.getAllRequiredPermissionsArray()
|
||||
}
|
||||
return arrayOf(
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
Manifest.permission.CAMERA
|
||||
)
|
||||
}
|
||||
|
||||
fun hasFullScreenIntentPermission(context: Context): Boolean {
|
||||
if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) {
|
||||
return Api34Compatibility.hasFullScreenIntentPermission(context)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun requestFullScreenIntentPermission(context: Context): Boolean {
|
||||
if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) {
|
||||
Api34Compatibility.requestFullScreenIntentPermission(context)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isPostNotificationsPermissionGranted(context: Context): Boolean {
|
||||
if (Version.sdkAboveOrEqual(Version.API33_ANDROID_13_TIRAMISU)) {
|
||||
return Api33Compatibility.isPostNotificationsPermissionGranted(context)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun enterPipMode(activity: Activity): Boolean {
|
||||
if (Version.sdkStrictlyBelow(Version.API31_ANDROID_12)) {
|
||||
return Api28Compatibility.enterPipMode(activity)
|
||||
}
|
||||
return activity.isInPictureInPictureMode
|
||||
}
|
||||
|
||||
fun enableAutoEnterPiP(activity: Activity, enable: Boolean) {
|
||||
if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) {
|
||||
Api31Compatibility.enableAutoEnterPiP(activity, enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun forceDarkMode(context: Context) {
|
||||
Log.i("$TAG Forcing dark/night theme")
|
||||
if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) {
|
||||
Api31Compatibility.forceDarkMode(context)
|
||||
} else {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
}
|
||||
|
||||
fun forceLightMode(context: Context) {
|
||||
Log.i("$TAG Forcing light/day theme")
|
||||
if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) {
|
||||
Api31Compatibility.forceLightMode(context)
|
||||
} else {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoLightDarkMode(context: Context) {
|
||||
Log.i("$TAG Following Android's choice for light/dark theme")
|
||||
if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) {
|
||||
Api31Compatibility.setAutoLightDarkMode(context)
|
||||
} else {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
}
|
||||
}
|
||||
|
||||
fun extractLocusIdFromIntent(intent: Intent): String? {
|
||||
if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10)) {
|
||||
return Api29Compatibility.extractLocusIdFromIntent(intent)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun setLocusIdInContentCaptureSession(root: View, conversationId: String) {
|
||||
if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10)) {
|
||||
return Api29Compatibility.setLocusIdInContentCaptureSession(
|
||||
root,
|
||||
conversationId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecordingsDirectory(): String {
|
||||
if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) {
|
||||
return Api31Compatibility.getRecordingsDirectory()
|
||||
}
|
||||
return Environment.DIRECTORY_PODCASTS
|
||||
}
|
||||
|
||||
fun setupAppStartupListener(context: Context) {
|
||||
if (Version.sdkAboveOrEqual(Version.API35_ANDROID_15_VANILLA_ICE_CREAM)) {
|
||||
Api35Compatibility.setupAppStartupListener(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun isIpAddress(string: String): Boolean {
|
||||
if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10)) {
|
||||
return Api29Compatibility.isIpAddress(string)
|
||||
}
|
||||
return Patterns.IP_ADDRESS.matcher(string).matches()
|
||||
}
|
||||
|
||||
fun sendPendingIntent(pendingIntent: PendingIntent, bundle: Bundle) {
|
||||
if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) {
|
||||
return Api34Compatibility.sendPendingIntent(pendingIntent, bundle)
|
||||
}
|
||||
pendingIntent.send()
|
||||
}
|
||||
|
||||
fun getPendingIntentActivityOptions(creator: Boolean): ActivityOptions {
|
||||
if (Version.sdkAboveOrEqual(Version.API36_ANDROID_16_BAKLAVA)) {
|
||||
return Api36Compatibility.getPendingIntentActivityOptions(creator)
|
||||
} else if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) {
|
||||
return Api34Compatibility.getPendingIntentActivityOptions(creator)
|
||||
} else if (Version.sdkAboveOrEqual(Version.API33_ANDROID_13_TIRAMISU)) {
|
||||
return Api33Compatibility.getPendingIntentActivityOptions()
|
||||
}
|
||||
|
||||
return ActivityOptions.makeBasic()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.contacts
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.core.ConsolidatedPresence
|
||||
import org.linphone.core.SecurityLevel
|
||||
|
||||
abstract class AbstractAvatarModel {
|
||||
val trust = MutableLiveData<SecurityLevel>()
|
||||
|
||||
val showTrust = MutableLiveData<Boolean>()
|
||||
|
||||
val initials = MutableLiveData<String>()
|
||||
|
||||
val picturePath = MutableLiveData<String>()
|
||||
|
||||
val forceConversationIcon = MutableLiveData<Boolean>()
|
||||
|
||||
val forceConferenceIcon = MutableLiveData<Boolean>()
|
||||
|
||||
val defaultToConversationIcon = MutableLiveData<Boolean>()
|
||||
|
||||
val defaultToConferenceIcon = MutableLiveData<Boolean>()
|
||||
|
||||
val skipInitials = MutableLiveData<Boolean>()
|
||||
|
||||
val presenceStatus = MutableLiveData<ConsolidatedPresence>()
|
||||
}
|
||||
119
app/src/main/java/org/linphone/contacts/AvatarGenerator.kt
Normal file
119
app/src/main/java/org/linphone/contacts/AvatarGenerator.kt
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2022 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.text.TextPaint
|
||||
import android.util.TypedValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import org.linphone.R
|
||||
import org.linphone.utils.AppUtils
|
||||
import androidx.core.graphics.createBitmap
|
||||
|
||||
class AvatarGenerator(private val context: Context) {
|
||||
private var textSize: Float = AppUtils.getDimension(R.dimen.avatar_initials_text_size)
|
||||
private var textColor: Int = ContextCompat.getColor(context, R.color.gray_main2_600)
|
||||
private var avatarSize: Int = AppUtils.getDimension(R.dimen.avatar_list_cell_size).toInt()
|
||||
private var initials = " "
|
||||
private var transparentColor: Int = ContextCompat.getColor(context, R.color.transparent_color)
|
||||
private var backgroundColor: Int = ContextCompat.getColor(context, R.color.gray_main2_200)
|
||||
|
||||
init {
|
||||
val textTypedValue = TypedValue()
|
||||
context.theme.resolveAttribute(R.attr.color_avatar_text, textTypedValue, true)
|
||||
// This will fail for notifications
|
||||
if (textTypedValue.data != 0) {
|
||||
textColor = textTypedValue.data
|
||||
}
|
||||
|
||||
val backgroundTypedValue = TypedValue()
|
||||
context.theme.resolveAttribute(R.attr.color_avatar_background, backgroundTypedValue, true)
|
||||
// This will fail for notifications
|
||||
if (backgroundTypedValue.data != 0) {
|
||||
backgroundColor = backgroundTypedValue.data
|
||||
}
|
||||
}
|
||||
|
||||
fun setTextSize(size: Float) = apply {
|
||||
textSize = size
|
||||
}
|
||||
|
||||
fun setAvatarSize(size: Int) = apply {
|
||||
avatarSize = size
|
||||
}
|
||||
|
||||
fun setInitials(label: String) = apply {
|
||||
initials = label
|
||||
}
|
||||
|
||||
fun buildBitmap(useTransparentBackground: Boolean): Bitmap {
|
||||
val textPainter = getTextPainter()
|
||||
val painter = if (useTransparentBackground) getTransparentPainter() else getBackgroundPainter()
|
||||
|
||||
val bitmap = createBitmap(avatarSize, avatarSize)
|
||||
val canvas = Canvas(bitmap)
|
||||
val areaRect = Rect(0, 0, avatarSize, avatarSize)
|
||||
val bounds = RectF(areaRect)
|
||||
bounds.right = textPainter.measureText(initials, 0, initials.length)
|
||||
bounds.bottom = textPainter.descent() - textPainter.ascent()
|
||||
bounds.left += (areaRect.width() - bounds.right) / 2.0f
|
||||
bounds.top += (areaRect.height() - bounds.bottom) / 2.0f
|
||||
|
||||
val halfSize = (avatarSize / 2).toFloat()
|
||||
canvas.drawCircle(halfSize, halfSize, halfSize, painter)
|
||||
canvas.drawText(initials, bounds.left, bounds.top - textPainter.ascent(), textPainter)
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun buildIcon(): IconCompat {
|
||||
return IconCompat.createWithAdaptiveBitmap(buildBitmap(false))
|
||||
}
|
||||
|
||||
private fun getTextPainter(): TextPaint {
|
||||
val textPainter = TextPaint()
|
||||
textPainter.isAntiAlias = true
|
||||
textPainter.textSize = textSize
|
||||
textPainter.color = textColor
|
||||
textPainter.typeface = ResourcesCompat.getFont(context, R.font.noto_sans_800)
|
||||
return textPainter
|
||||
}
|
||||
|
||||
private fun getTransparentPainter(): Paint {
|
||||
val painter = Paint()
|
||||
painter.isAntiAlias = true
|
||||
painter.color = transparentColor
|
||||
return painter
|
||||
}
|
||||
|
||||
private fun getBackgroundPainter(): Paint {
|
||||
val painter = Paint()
|
||||
painter.isAntiAlias = true
|
||||
painter.color = backgroundColor
|
||||
return painter
|
||||
}
|
||||
}
|
||||
345
app/src/main/java/org/linphone/contacts/ContactLoader.kt
Normal file
345
app/src/main/java/org/linphone/contacts/ContactLoader.kt
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.contacts
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.StaleDataException
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Patterns
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.CursorLoader
|
||||
import androidx.loader.content.Loader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.Exception
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Core
|
||||
import org.linphone.core.Factory
|
||||
import org.linphone.core.Friend
|
||||
import org.linphone.core.FriendList
|
||||
import org.linphone.core.GlobalState
|
||||
import org.linphone.core.SubscribePolicy
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.PhoneNumberUtils
|
||||
|
||||
class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
||||
companion object {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.Data.CONTACT_ID,
|
||||
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
|
||||
ContactsContract.Data.MIMETYPE,
|
||||
ContactsContract.Contacts.STARRED,
|
||||
ContactsContract.Contacts.LOOKUP_KEY,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE,
|
||||
ContactsContract.CommonDataKinds.Phone.LABEL,
|
||||
ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER
|
||||
)
|
||||
|
||||
private const val TAG = "[Contacts Loader]"
|
||||
|
||||
const val NATIVE_ADDRESS_BOOK_FRIEND_LIST = "Native address-book"
|
||||
const val LINPHONE_ADDRESS_BOOK_FRIEND_LIST = "Linphone address-book"
|
||||
|
||||
private const val MIN_INTERVAL_TO_WAIT_BEFORE_REFRESH = 300000L // 5 minutes
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@MainThread
|
||||
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
|
||||
Log.i("$TAG Creating and starting cursor loader")
|
||||
val mimeType = ContactsContract.Data.MIMETYPE
|
||||
val mimeSelection = "$mimeType = ? OR $mimeType = ? OR $mimeType = ? OR $mimeType = ?"
|
||||
|
||||
val selection = if (args?.getBoolean("defaultDirectory", true) == true) {
|
||||
Log.i("$TAG Only fetching contacts from default directory")
|
||||
ContactsContract.Data.IN_DEFAULT_DIRECTORY + " == 1 AND ($mimeSelection)"
|
||||
} else {
|
||||
Log.i("$TAG Fetching all available contacts")
|
||||
mimeSelection
|
||||
}
|
||||
|
||||
val selectionArgs = arrayOf(
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,
|
||||
ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE,
|
||||
ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE
|
||||
)
|
||||
|
||||
val loader = CursorLoader(
|
||||
coreContext.context,
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
ContactsContract.Data.CONTACT_ID + " ASC"
|
||||
)
|
||||
|
||||
// WARNING: this doesn't prevent to be called again in onLoadFinished,
|
||||
// it will only have for effect that the notified cursor will be the same as before
|
||||
// instead of a new one with updated content!
|
||||
// loader.setUpdateThrottle(MIN_INTERVAL_TO_WAIT_BEFORE_REFRESH)
|
||||
|
||||
return loader
|
||||
}
|
||||
|
||||
@MainThread
|
||||
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
|
||||
if (cursor == null) {
|
||||
Log.e("$TAG Cursor is null!")
|
||||
return
|
||||
} else if (cursor.isClosed) {
|
||||
Log.e("$TAG Cursor is closed!")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i("$TAG Load finished, found ${cursor.count} entries in cursor")
|
||||
if (cursor.isAfterLast) {
|
||||
Log.w("$TAG Cursor position is after last, it was probably already used, nothing to do")
|
||||
return
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
val core = coreContext.core
|
||||
val state = core.globalState
|
||||
if (state == GlobalState.Shutdown || state == GlobalState.Off) {
|
||||
Log.w("$TAG Core is being stopped or already destroyed, abort")
|
||||
} else {
|
||||
scope.launch {
|
||||
parseFriends(core, cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
override fun onLoaderReset(loader: Loader<Cursor>) {
|
||||
Log.i("$TAG Loader reset")
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun parseFriends(core: Core, cursor: Cursor) {
|
||||
try {
|
||||
val contactIdColumn = cursor.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
|
||||
val mimetypeColumn = cursor.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE)
|
||||
val displayNameColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.Data.DISPLAY_NAME_PRIMARY
|
||||
)
|
||||
val starredColumn = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.STARRED)
|
||||
val lookupColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.Contacts.LOOKUP_KEY
|
||||
)
|
||||
val phoneNumberColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER
|
||||
)
|
||||
val phoneTypeColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE
|
||||
)
|
||||
val phoneLabelColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.Phone.LABEL
|
||||
)
|
||||
val normalizedPhoneColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER
|
||||
)
|
||||
val sipAddressColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS
|
||||
)
|
||||
val companyColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.Organization.COMPANY
|
||||
)
|
||||
val jobTitleColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.Organization.TITLE
|
||||
)
|
||||
val givenNameColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME
|
||||
)
|
||||
val familyNameColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME
|
||||
)
|
||||
|
||||
val friends = HashMap<String, Friend>()
|
||||
while (!cursor.isClosed && cursor.moveToNext()) {
|
||||
try {
|
||||
val id: String = cursor.getString(contactIdColumn)
|
||||
val mime: String? = cursor.getString(mimetypeColumn)
|
||||
|
||||
val friend = friends[id] ?: core.createFriend()
|
||||
friend.refKey = id
|
||||
if (friend.name.isNullOrEmpty()) {
|
||||
val displayName: String? = cursor.getString(displayNameColumn)
|
||||
if (!displayName.isNullOrEmpty()) {
|
||||
friend.name = displayName
|
||||
|
||||
val uri = friend.getNativeContactPictureUri()
|
||||
if (uri != null) {
|
||||
friend.photo = uri.toString()
|
||||
}
|
||||
|
||||
val starred = cursor.getInt(starredColumn) == 1
|
||||
friend.starred = starred
|
||||
|
||||
val lookupKey =
|
||||
cursor.getString(lookupColumn)
|
||||
friend.nativeUri =
|
||||
"${ContactsContract.Contacts.CONTENT_LOOKUP_URI}/$lookupKey"
|
||||
|
||||
friend.isSubscribesEnabled = false
|
||||
// Disable peer to peer short term presence
|
||||
friend.incSubscribePolicy = SubscribePolicy.SPDeny
|
||||
}
|
||||
}
|
||||
|
||||
when (mime) {
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> {
|
||||
val data1: String? = cursor.getString(phoneNumberColumn)
|
||||
val data2: String? = cursor.getString(phoneTypeColumn)
|
||||
val data3: String? = cursor.getString(phoneLabelColumn)
|
||||
val data4: String? = cursor.getString(normalizedPhoneColumn)
|
||||
|
||||
val label =
|
||||
PhoneNumberUtils.addressBookLabelTypeToVcardParamString(
|
||||
data2?.toInt()
|
||||
?: ContactsContract.CommonDataKinds.BaseTypes.TYPE_CUSTOM,
|
||||
data3
|
||||
)
|
||||
|
||||
val number =
|
||||
if (data1.isNullOrEmpty() ||
|
||||
!Patterns.PHONE.matcher(data1).matches()
|
||||
) {
|
||||
data4 ?: data1
|
||||
} else {
|
||||
data1
|
||||
}
|
||||
|
||||
if (!number.isNullOrEmpty()) {
|
||||
val phoneNumber = Factory.instance()
|
||||
.createFriendPhoneNumber(number, label)
|
||||
friend.addPhoneNumberWithLabel(phoneNumber)
|
||||
}
|
||||
}
|
||||
ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> {
|
||||
val sipAddress: String? = cursor.getString(sipAddressColumn)
|
||||
if (!sipAddress.isNullOrEmpty()) {
|
||||
val address = core.interpretUrl(sipAddress, false)
|
||||
if (address != null) {
|
||||
friend.addAddress(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE -> {
|
||||
val organization: String? = cursor.getString(companyColumn)
|
||||
if (!organization.isNullOrEmpty()) {
|
||||
friend.organization = organization
|
||||
}
|
||||
|
||||
val job: String? = cursor.getString(jobTitleColumn)
|
||||
if (!job.isNullOrEmpty()) {
|
||||
friend.jobTitle = job
|
||||
}
|
||||
}
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
|
||||
val givenName: String? = cursor.getString(givenNameColumn)
|
||||
if (!givenName.isNullOrEmpty()) {
|
||||
friend.firstName = givenName
|
||||
}
|
||||
|
||||
val familyName: String? = cursor.getString(familyNameColumn)
|
||||
if (!familyName.isNullOrEmpty()) {
|
||||
friend.lastName = familyName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
friends[id] = friend
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Exception: $e")
|
||||
}
|
||||
}
|
||||
|
||||
Log.i("$TAG Contacts parsed, posting another task to handle adding them (or not)")
|
||||
// Re-post another task to allow other tasks on Core thread
|
||||
coreContext.postOnCoreThreadWhenAvailableForHeavyTask({
|
||||
addFriendsIfNeeded(friends)
|
||||
}, "add friends to Core")
|
||||
} catch (sde: StaleDataException) {
|
||||
Log.e("$TAG State Data Exception: $sde")
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e("$TAG Illegal State Exception: $ise")
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Exception: $e")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun addFriendsIfNeeded(friends: HashMap<String, Friend>) {
|
||||
val core = coreContext.core
|
||||
|
||||
if (core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off) {
|
||||
Log.w("$TAG Core is being stopped or already destroyed, abort")
|
||||
} else if (friends.isEmpty()) {
|
||||
Log.w("$TAG No friend created!")
|
||||
} else {
|
||||
Log.i("$TAG ${friends.size} friends fetched")
|
||||
|
||||
val friendsList = core.getFriendListByName(NATIVE_ADDRESS_BOOK_FRIEND_LIST)
|
||||
?: core.createFriendList()
|
||||
if (friendsList.displayName.isNullOrEmpty()) {
|
||||
Log.i(
|
||||
"$TAG Friend list [$NATIVE_ADDRESS_BOOK_FRIEND_LIST] didn't exist yet, let's create it"
|
||||
)
|
||||
friendsList.isDatabaseStorageEnabled =
|
||||
true // Store them to keep presence info available for push notifications & favorites
|
||||
friendsList.type = FriendList.Type.Default
|
||||
friendsList.displayName = NATIVE_ADDRESS_BOOK_FRIEND_LIST
|
||||
core.addFriendList(friendsList)
|
||||
|
||||
for (friend in friends.values) {
|
||||
friendsList.addLocalFriend(friend)
|
||||
}
|
||||
Log.i("$TAG Friends added")
|
||||
} else {
|
||||
val friendsArray = friends.values.toTypedArray()
|
||||
Log.i(
|
||||
"$TAG Friend list [$NATIVE_ADDRESS_BOOK_FRIEND_LIST] found, synchronizing existing friends with new ones"
|
||||
)
|
||||
val changes = friendsList.synchronizeFriendsWith(friendsArray)
|
||||
if (changes) {
|
||||
Log.i("$TAG Locally stored friends synchronized with native address book")
|
||||
} else {
|
||||
Log.i("$TAG No changes detected between native address book and local friends storage")
|
||||
}
|
||||
}
|
||||
friends.clear()
|
||||
|
||||
friendsList.updateSubscriptions()
|
||||
Log.i("$TAG Subscription(s) updated")
|
||||
coreContext.contactsManager.onNativeContactsLoaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
888
app/src/main/java/org/linphone/contacts/ContactsManager.kt
Normal file
888
app/src/main/java/org/linphone/contacts/ContactsManager.kt
Normal file
|
|
@ -0,0 +1,888 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.contacts
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import androidx.loader.app.LoaderManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.core.Account
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.ConferenceInfo
|
||||
import org.linphone.core.Core
|
||||
import org.linphone.core.CoreListenerStub
|
||||
import org.linphone.core.Factory
|
||||
import org.linphone.core.Friend
|
||||
import org.linphone.core.FriendList
|
||||
import org.linphone.core.FriendListListenerStub
|
||||
import org.linphone.core.MagicSearch
|
||||
import org.linphone.core.MagicSearchListenerStub
|
||||
import org.linphone.core.SecurityLevel
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.main.MainActivity
|
||||
import org.linphone.ui.main.contacts.model.ContactAvatarModel
|
||||
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener
|
||||
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel
|
||||
import org.linphone.ui.main.model.isEndToEndEncryptionMandatory
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.ImageUtils
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
import org.linphone.utils.PhoneNumberUtils
|
||||
import org.linphone.utils.ShortcutUtils
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class ContactsManager
|
||||
@UiThread
|
||||
constructor() {
|
||||
companion object {
|
||||
private const val TAG = "[Contacts Manager]"
|
||||
|
||||
private const val DELAY_BEFORE_RELOADING_CONTACTS_AFTER_PRESENCE_RECEIVED = 1000L // 1 second
|
||||
private const val DELAY_BEFORE_RELOADING_CONTACTS_AFTER_MAGIC_SEARCH_RESULT = 1000L // 1 second
|
||||
private const val FRIEND_LIST_TEMPORARY_STORED_REMOTE_DIRECTORY = "TempRemoteDirectoryContacts"
|
||||
}
|
||||
|
||||
private var nativeContactsLoaded = false
|
||||
|
||||
private val listeners = arrayListOf<ContactsListener>()
|
||||
|
||||
private val knownContactsAvatarsMap = hashMapOf<String, ContactAvatarModel>()
|
||||
private val unknownContactsAvatarsMap = hashMapOf<String, ContactAvatarModel>()
|
||||
private val conferenceAvatarMap = hashMapOf<String, ContactAvatarModel>()
|
||||
private val magicSearchMap = hashMapOf<String, MagicSearch>()
|
||||
|
||||
private val unknownRemoteContactDirectoriesContactsMap = arrayListOf<String>()
|
||||
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var reloadPresenceContactsJob: Job? = null
|
||||
private var reloadRemoteContactsJob: Job? = null
|
||||
|
||||
private var loadContactsOnlyFromDefaultDirectory = true
|
||||
|
||||
private val magicSearchListener = object : MagicSearchListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onSearchResultsReceived(magicSearch: MagicSearch) {
|
||||
var queriedSipUri = ""
|
||||
for ((key, value) in magicSearchMap.entries) {
|
||||
if (value == magicSearch) {
|
||||
queriedSipUri = key
|
||||
}
|
||||
}
|
||||
|
||||
val results = magicSearch.lastSearch
|
||||
Log.i(
|
||||
"$TAG [${results.size}] magic search results available for query upon SIP URI [$queriedSipUri]"
|
||||
)
|
||||
|
||||
var found = false
|
||||
if (results.isNotEmpty()) {
|
||||
val result = results.first { it.friend != null }
|
||||
if (result != null) {
|
||||
val friend = result.friend!!
|
||||
Log.i("$TAG Found matching friend in source [${result.sourceFlags}]")
|
||||
val address = result.address?.asStringUriOnly().orEmpty()
|
||||
if (address.isEmpty() || (queriedSipUri.isNotEmpty() && queriedSipUri != address)) {
|
||||
Log.w("$TAG Received friend [${friend.name}] with SIP URI [$address] doesn't match queried SIP URI [$queriedSipUri]")
|
||||
} else {
|
||||
found = true
|
||||
reloadRemoteContactsJob?.cancel()
|
||||
|
||||
// Store friend in app's cache to be re-used in call history, conversations, etc...
|
||||
val temporaryFriendList = getRemoteContactDirectoriesCacheFriendList()
|
||||
temporaryFriendList.addFriend(friend)
|
||||
newContactAdded(friend)
|
||||
Log.i(
|
||||
"$TAG Stored discovered friend [${friend.name}] in temporary friend list, for later use"
|
||||
)
|
||||
|
||||
for (listener in listeners) {
|
||||
listener.onContactFoundInRemoteDirectory(friend)
|
||||
}
|
||||
|
||||
reloadRemoteContactsJob = coroutineScope.launch {
|
||||
delay(DELAY_BEFORE_RELOADING_CONTACTS_AFTER_MAGIC_SEARCH_RESULT)
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG At least a new SIP address was discovered, reloading contacts")
|
||||
conferenceAvatarMap.values.forEach(ContactAvatarModel::destroy)
|
||||
conferenceAvatarMap.clear()
|
||||
|
||||
notifyContactsListChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (queriedSipUri.isNotEmpty()) {
|
||||
magicSearchMap.remove(queriedSipUri)
|
||||
if (!found) {
|
||||
Log.i(
|
||||
"$TAG SIP URI [$queriedSipUri] wasn't found in remote directories, adding it to unknown list to prevent further queries"
|
||||
)
|
||||
unknownRemoteContactDirectoriesContactsMap.add(queriedSipUri)
|
||||
}
|
||||
}
|
||||
magicSearch.removeListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
private val friendListListener: FriendListListenerStub = object : FriendListListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onPresenceReceived(friendList: FriendList, friends: Array<out Friend?>) {
|
||||
if (friendList.isSubscriptionBodyless) {
|
||||
Log.i("$TAG Bodyless friendlist [${friendList.displayName}] presence received")
|
||||
|
||||
var atLeastOneFriendAdded = false
|
||||
for (friend in friends) {
|
||||
if (friend != null) {
|
||||
val address = friend.address
|
||||
if (address != null) {
|
||||
Log.i(
|
||||
"$TAG Newly discovered SIP Address [${address.asStringUriOnly()}] for friend [${friend.name}] in bodyless list [${friendList.displayName}]"
|
||||
)
|
||||
newContactAddedWithSipUri(friend, address)
|
||||
atLeastOneFriendAdded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (atLeastOneFriendAdded) {
|
||||
notifyContactsListChanged()
|
||||
} else {
|
||||
Log.w("$TAG No new friend detected in the received bodyless friendlist, not refreshing contacts in app")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onNewSipAddressDiscovered(
|
||||
friendList: FriendList,
|
||||
friend: Friend,
|
||||
sipUri: String
|
||||
) {
|
||||
reloadPresenceContactsJob?.cancel()
|
||||
Log.d(
|
||||
"$TAG Newly discovered SIP Address [$sipUri] for friend [${friend.name}] in list [${friendList.displayName}]"
|
||||
)
|
||||
val address = Factory.instance().createAddress(sipUri)
|
||||
if (address != null) {
|
||||
Log.i("$TAG Storing discovered SIP URI inside Friend")
|
||||
friend.edit()
|
||||
friend.addAddress(address)
|
||||
friend.done()
|
||||
|
||||
newContactAddedWithSipUri(friend, address)
|
||||
} else {
|
||||
Log.e("$TAG Failed to parse SIP URI [$sipUri] as Address!")
|
||||
}
|
||||
|
||||
reloadPresenceContactsJob = coroutineScope.launch {
|
||||
delay(DELAY_BEFORE_RELOADING_CONTACTS_AFTER_PRESENCE_RECEIVED)
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG At least a new SIP address was discovered, reloading contacts")
|
||||
conferenceAvatarMap.values.forEach(ContactAvatarModel::destroy)
|
||||
conferenceAvatarMap.clear()
|
||||
|
||||
notifyContactsListChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onContactCreated(friendList: FriendList, linphoneFriend: Friend) {
|
||||
for (address in linphoneFriend.addresses) {
|
||||
removeUnknownAddressFromMap(address)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onContactDeleted(friendList: FriendList, linphoneFriend: Friend) {
|
||||
for (address in linphoneFriend.addresses) {
|
||||
removeKnownAddressFromMap(address)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onContactUpdated(
|
||||
friendList: FriendList,
|
||||
newFriend: Friend,
|
||||
oldFriend: Friend
|
||||
) {
|
||||
for (address in oldFriend.addresses) {
|
||||
removeKnownAddressFromMap(address)
|
||||
}
|
||||
for (address in newFriend.addresses) {
|
||||
removeUnknownAddressFromMap(address)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onSyncStatusChanged(
|
||||
friendList: FriendList,
|
||||
status: FriendList.SyncStatus?,
|
||||
message: String?
|
||||
) {
|
||||
Log.i("$TAG Friend list [${friendList.displayName}] sync status changed to [$status]")
|
||||
when (status) {
|
||||
FriendList.SyncStatus.Successful -> {
|
||||
notifyContactsListChanged()
|
||||
}
|
||||
FriendList.SyncStatus.Failure -> {
|
||||
Log.e("$TAG Friend list [${friendList.displayName}] sync failed: $message")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val coreListener: CoreListenerStub = object : CoreListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onFriendListCreated(core: Core, friendList: FriendList) {
|
||||
Log.i("$TAG Friend list [${friendList.displayName}] created")
|
||||
friendList.addListener(friendListListener)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onFriendListRemoved(core: Core, friendList: FriendList) {
|
||||
Log.i("$TAG Friend list [${friendList.displayName}] removed")
|
||||
friendList.removeListener(friendListListener)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onDefaultAccountChanged(core: Core, account: Account?) {
|
||||
Log.i("$TAG Default account changed, update all contacts' model showTrust value")
|
||||
updateContactsModelDependingOnDefaultAccountMode()
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun loadContacts(activity: MainActivity) {
|
||||
Log.i("$TAG Starting contacts loader")
|
||||
val manager = LoaderManager.getInstance(activity)
|
||||
val args = Bundle()
|
||||
args.putBoolean("defaultDirectory", loadContactsOnlyFromDefaultDirectory)
|
||||
manager.restartLoader(0, args, ContactLoader())
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun addListener(listener: ContactsListener) {
|
||||
// Post again to prevent ConcurrentModificationException
|
||||
coreContext.postOnCoreThread {
|
||||
try {
|
||||
listeners.add(listener)
|
||||
} catch (cme: ConcurrentModificationException) {
|
||||
Log.e("$TAG Can't add listener: $cme")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun removeListener(listener: ContactsListener) {
|
||||
if (coreContext.isReady()) {
|
||||
// Post again to prevent ConcurrentModificationException
|
||||
coreContext.postOnCoreThread {
|
||||
try {
|
||||
listeners.remove(listener)
|
||||
} catch (cme: ConcurrentModificationException) {
|
||||
Log.e("$TAG Can't remove listener: $cme")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun removeKnownAddressFromMap(address: Address) {
|
||||
val key = address.asStringUriOnly()
|
||||
val wasKnown = knownContactsAvatarsMap.remove(key)
|
||||
if (wasKnown != null) {
|
||||
Log.d("$TAG Removed address [$key] from knownContactsAvatarsMap")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun removeUnknownAddressFromMap(address: Address) {
|
||||
val key = address.asStringUriOnly()
|
||||
val wasUnknown = unknownContactsAvatarsMap.remove(key)
|
||||
if (wasUnknown != null) {
|
||||
Log.d("$TAG Removed address [$key] from unknownContactsAvatarsMap")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun newContactAddedWithSipUri(friend: Friend, address: Address) {
|
||||
val sipUri = address.asStringUriOnly()
|
||||
if (unknownContactsAvatarsMap.keys.contains(sipUri)) {
|
||||
Log.d("$TAG Found SIP URI [$sipUri] in unknownContactsAvatarsMap, removing it")
|
||||
val oldModel = unknownContactsAvatarsMap[sipUri]
|
||||
oldModel?.destroy()
|
||||
unknownContactsAvatarsMap.remove(sipUri)
|
||||
} else if (knownContactsAvatarsMap.keys.contains(sipUri)) {
|
||||
Log.d(
|
||||
"$TAG Found SIP URI [$sipUri] in knownContactsAvatarsMap, forcing presence update"
|
||||
)
|
||||
val oldModel = knownContactsAvatarsMap[sipUri]
|
||||
oldModel?.update(address)
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG New contact added with SIP URI [$sipUri] but no avatar yet, let's create it"
|
||||
)
|
||||
val model = ContactAvatarModel(friend)
|
||||
knownContactsAvatarsMap[sipUri] = model
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun newContactAdded(friend: Friend) {
|
||||
for (sipAddress in friend.addresses) {
|
||||
newContactAddedWithSipUri(friend, sipAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun contactRemoved(friend: Friend) {
|
||||
val refKey = friend.refKey.orEmpty()
|
||||
if (refKey.isNotEmpty() && knownContactsAvatarsMap.keys.contains(refKey)) {
|
||||
Log.d("$TAG Found RefKey [$refKey] in knownContactsAvatarsMap, removing it")
|
||||
val oldModel = knownContactsAvatarsMap[refKey]
|
||||
oldModel?.destroy()
|
||||
knownContactsAvatarsMap.remove(refKey)
|
||||
}
|
||||
|
||||
for (sipAddress in friend.addresses) {
|
||||
val sipUri = sipAddress.asStringUriOnly()
|
||||
if (knownContactsAvatarsMap.keys.contains(sipUri)) {
|
||||
Log.d("$TAG Found SIP URI [$sipUri] in knownContactsAvatarsMap, removing it")
|
||||
val oldModel = knownContactsAvatarsMap[sipUri]
|
||||
oldModel?.destroy()
|
||||
knownContactsAvatarsMap.remove(sipUri)
|
||||
}
|
||||
}
|
||||
|
||||
conferenceAvatarMap.values.forEach(ContactAvatarModel::destroy)
|
||||
conferenceAvatarMap.clear()
|
||||
notifyContactsListChanged()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun onNativeContactsLoaded() {
|
||||
nativeContactsLoaded = true
|
||||
Log.i("$TAG Native contacts have been loaded, cleaning avatars maps")
|
||||
|
||||
knownContactsAvatarsMap.values.forEach(ContactAvatarModel::destroy)
|
||||
knownContactsAvatarsMap.clear()
|
||||
unknownContactsAvatarsMap.values.forEach(ContactAvatarModel::destroy)
|
||||
unknownContactsAvatarsMap.clear()
|
||||
conferenceAvatarMap.values.forEach(ContactAvatarModel::destroy)
|
||||
conferenceAvatarMap.clear()
|
||||
unknownRemoteContactDirectoriesContactsMap.clear()
|
||||
|
||||
notifyContactsListChanged()
|
||||
|
||||
Log.i("$TAG Native contacts have been loaded")
|
||||
// No longer create chat room shortcuts depending on most recents ones, create it when a message is sent or received instead
|
||||
// ShortcutUtils.createShortcutsToChatRooms(coreContext.context)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun notifyContactsListChanged() {
|
||||
for (listener in listeners) {
|
||||
listener.onContactsLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun findContactById(id: String): Friend? {
|
||||
Log.d("$TAG Looking for a friend with ref key [$id]")
|
||||
for (friendList in coreContext.core.friendsLists) {
|
||||
val found = friendList.findFriendByRefKey(id)
|
||||
if (found != null) {
|
||||
Log.d("$TAG Found friend [${found.name}] matching ref key [$id]")
|
||||
return found
|
||||
}
|
||||
}
|
||||
Log.w("$TAG No friend matching ref key [$id] has been found")
|
||||
return null
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun findContactByAddress(address: Address): Friend? {
|
||||
Log.i("$TAG Looking for friend matching SIP address [${address.asStringUriOnly()}]")
|
||||
val found = coreContext.core.findFriend(address)
|
||||
if (found != null) {
|
||||
Log.i("$TAG Found friend [${found.name}] matching SIP address [${address.asStringUriOnly()}]")
|
||||
return found
|
||||
}
|
||||
|
||||
val username = address.username
|
||||
val sipUri = LinphoneUtils.getAddressAsCleanStringUriOnly(address)
|
||||
// Start an async query in Magic Search in case LDAP or remote CardDAV is configured
|
||||
val remoteContactDirectories = coreContext.core.remoteContactDirectories
|
||||
if (remoteContactDirectories.isNotEmpty() && !magicSearchMap.keys.contains(sipUri) && !unknownRemoteContactDirectoriesContactsMap.contains(
|
||||
sipUri
|
||||
)
|
||||
) {
|
||||
Log.i(
|
||||
"$TAG SIP URI [$sipUri] not found in locally stored Friends, trying LDAP/CardDAV remote directory"
|
||||
)
|
||||
|
||||
val magicSearch = coreContext.core.createMagicSearch()
|
||||
magicSearch.addListener(magicSearchListener)
|
||||
magicSearchMap[sipUri] = magicSearch
|
||||
|
||||
magicSearch.getContactsListAsync(
|
||||
username,
|
||||
address.domain,
|
||||
MagicSearch.Source.LdapServers.toInt() or MagicSearch.Source.RemoteCardDAV.toInt(),
|
||||
MagicSearch.Aggregation.Friend
|
||||
)
|
||||
}
|
||||
|
||||
return if (!username.isNullOrEmpty() && (username.startsWith("+") || username.isDigitsOnly())) {
|
||||
Log.i("$TAG Looking for friend using phone number [$username]")
|
||||
val foundUsingPhoneNumber = coreContext.core.findFriendByPhoneNumber(username)
|
||||
if (foundUsingPhoneNumber != null) {
|
||||
Log.i("$TAG Found friend [${foundUsingPhoneNumber.name}] matching phone number [$username]")
|
||||
}
|
||||
foundUsingPhoneNumber
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun findDisplayName(address: Address): String {
|
||||
return getContactAvatarModelForAddress(address).friend.name ?: LinphoneUtils.getDisplayName(
|
||||
address
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getContactAvatarModelForAddress(address: Address?): ContactAvatarModel {
|
||||
if (address == null) {
|
||||
Log.w("$TAG Address is null, generic model will be used")
|
||||
val fakeFriend = coreContext.core.createFriend()
|
||||
return ContactAvatarModel(fakeFriend)
|
||||
}
|
||||
|
||||
val clone = address.clone()
|
||||
clone.clean()
|
||||
val key = clone.asStringUriOnly()
|
||||
|
||||
val foundInMap = getAvatarModelFromCache(key)
|
||||
if (foundInMap != null) {
|
||||
Log.d("$TAG Avatar model found in map for SIP URI [$key]")
|
||||
return foundInMap
|
||||
}
|
||||
|
||||
val localAccount = coreContext.core.accountList.find {
|
||||
it.params.identityAddress?.weakEqual(clone) == true
|
||||
}
|
||||
val avatar = if (localAccount != null) {
|
||||
Log.d("$TAG [$key] SIP URI matches one of the local account")
|
||||
val fakeFriend = coreContext.core.createFriend()
|
||||
fakeFriend.address = clone
|
||||
fakeFriend.name = LinphoneUtils.getDisplayName(localAccount.params.identityAddress)
|
||||
fakeFriend.photo = localAccount.params.pictureUri
|
||||
val model = ContactAvatarModel(fakeFriend)
|
||||
model.trust.postValue(SecurityLevel.EndToEndEncryptedAndVerified)
|
||||
unknownContactsAvatarsMap[key] = model
|
||||
model
|
||||
} else {
|
||||
Log.d("$TAG Looking for friend matching SIP URI [$key]")
|
||||
val friend = findContactByAddress(clone)
|
||||
if (friend != null) {
|
||||
Log.d("$TAG Matching friend [${friend.name}] found for SIP URI [$key]")
|
||||
val model = ContactAvatarModel(friend, address)
|
||||
knownContactsAvatarsMap[key] = model
|
||||
model
|
||||
} else {
|
||||
Log.d("$TAG No matching friend found for SIP URI [$key]...")
|
||||
val fakeFriend = coreContext.core.createFriend()
|
||||
fakeFriend.name = LinphoneUtils.getDisplayName(address)
|
||||
fakeFriend.address = clone
|
||||
val model = ContactAvatarModel(fakeFriend)
|
||||
unknownContactsAvatarsMap[key] = model
|
||||
model
|
||||
}
|
||||
}
|
||||
|
||||
return avatar
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getContactAvatarModelForFriend(friend: Friend?): ContactAvatarModel {
|
||||
if (friend == null) {
|
||||
Log.w("$TAG Friend is null, using generic avatar model")
|
||||
val fakeFriend = coreContext.core.createFriend()
|
||||
return ContactAvatarModel(fakeFriend)
|
||||
}
|
||||
|
||||
val avatar = ContactAvatarModel(friend)
|
||||
return avatar
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getContactAvatarModelForConferenceInfo(conferenceInfo: ConferenceInfo): ContactAvatarModel {
|
||||
// Do not clean parameters!
|
||||
val key = conferenceInfo.uri?.asStringUriOnly()
|
||||
if (key == null) {
|
||||
val fakeFriend = coreContext.core.createFriend()
|
||||
fakeFriend.name = conferenceInfo.subject
|
||||
val model = ContactAvatarModel(fakeFriend)
|
||||
model.showTrust.postValue(false)
|
||||
return model
|
||||
}
|
||||
|
||||
val foundInMap = conferenceAvatarMap[key] ?: conferenceAvatarMap[key]
|
||||
if (foundInMap != null) return foundInMap
|
||||
|
||||
val avatar = LinphoneUtils.getAvatarModelForConferenceInfo(conferenceInfo)
|
||||
conferenceAvatarMap[key] = avatar
|
||||
|
||||
return avatar
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun isContactAvailable(friend: Friend): Boolean {
|
||||
return !friend.refKey.isNullOrEmpty() && !isContactTemporary(friend)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun isContactTemporary(friend: Friend, allowNullFriendList: Boolean = false): Boolean {
|
||||
val friendList = friend.friendList
|
||||
if (friendList == null && !allowNullFriendList) return true
|
||||
return friendList?.type == FriendList.Type.ApplicationCache
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun onCoreStarted(core: Core) {
|
||||
Log.i("$TAG Core has been started")
|
||||
loadContactsOnlyFromDefaultDirectory = corePreferences.fetchContactsFromDefaultDirectory
|
||||
|
||||
core.addListener(coreListener)
|
||||
for (list in core.friendsLists) {
|
||||
Log.i("$TAG Found existing friend list [${list.displayName}]")
|
||||
list.addListener(friendListListener)
|
||||
}
|
||||
|
||||
val context = coreContext.context
|
||||
ShortcutUtils.removeLegacyShortcuts(context)
|
||||
// No longer create chat room shortcuts depending on most recents ones, create it when a message is sent or received instead
|
||||
/*if (ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_CONTACTS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.w("$TAG READ_CONTACTS permission was denied, creating chat rooms shortcuts now")
|
||||
ShortcutUtils.createShortcutsToChatRooms(context)
|
||||
}*/
|
||||
|
||||
for (list in core.friendsLists) {
|
||||
if (list.type == FriendList.Type.CardDAV && !list.uri.isNullOrEmpty()) {
|
||||
Log.i(
|
||||
"$TAG Found a CardDAV friend list with name [${list.displayName}] and URI [${list.uri}], synchronizing it"
|
||||
)
|
||||
list.synchronizeFriendsFromServer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun onCoreStopped(core: Core) {
|
||||
Log.w("$TAG Core has been stopped")
|
||||
coroutineScope.cancel()
|
||||
|
||||
core.removeListener(coreListener)
|
||||
|
||||
for (list in core.friendsLists) {
|
||||
list.removeListener(friendListListener)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getRemoteContactDirectoriesCacheFriendList(): FriendList {
|
||||
val core = coreContext.core
|
||||
val name = FRIEND_LIST_TEMPORARY_STORED_REMOTE_DIRECTORY
|
||||
val temporaryFriendList = core.getFriendListByName(name) ?: core.createFriendList()
|
||||
if (temporaryFriendList.displayName.isNullOrEmpty()) {
|
||||
temporaryFriendList.isDatabaseStorageEnabled = false
|
||||
temporaryFriendList.displayName = name
|
||||
temporaryFriendList.type = FriendList.Type.ApplicationCache
|
||||
core.addFriendList(temporaryFriendList)
|
||||
Log.i(
|
||||
"$TAG Created temporary friend list with name [$name]"
|
||||
)
|
||||
}
|
||||
return temporaryFriendList
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getMePerson(localAddress: Address): Person {
|
||||
val account = coreContext.core.accountList.find {
|
||||
it.params.identityAddress?.weakEqual(localAddress) == true
|
||||
}
|
||||
val name = account?.params?.identityAddress?.displayName ?: LinphoneUtils.getDisplayName(
|
||||
localAddress
|
||||
)
|
||||
val personBuilder = Person.Builder().setName(name.ifEmpty { "Unknown" })
|
||||
|
||||
val photo = account?.params?.pictureUri.orEmpty()
|
||||
val bm = ImageUtils.getBitmap(coreContext.context, photo)
|
||||
personBuilder.setIcon(
|
||||
if (bm == null) {
|
||||
AvatarGenerator(coreContext.context).setInitials(AppUtils.getInitials(name)).buildIcon()
|
||||
} else {
|
||||
IconCompat.createWithAdaptiveBitmap(bm)
|
||||
}
|
||||
)
|
||||
|
||||
val identity = account?.params?.identityAddress?.asStringUriOnly() ?: localAddress.asStringUriOnly()
|
||||
personBuilder.setKey(identity)
|
||||
personBuilder.setImportant(true)
|
||||
return personBuilder.build()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun updateContactsModelDependingOnDefaultAccountMode() {
|
||||
val showTrust = true
|
||||
Log.i(
|
||||
"$TAG Default account mode is [${if (showTrust) "end-to-end encryption mandatory" else "interoperable"}], update all contact models showTrust value"
|
||||
)
|
||||
knownContactsAvatarsMap.forEach { (_, contactAvatarModel) ->
|
||||
contactAvatarModel.showTrust.postValue(showTrust)
|
||||
}
|
||||
unknownContactsAvatarsMap.forEach { (_, contactAvatarModel) ->
|
||||
contactAvatarModel.showTrust.postValue(showTrust)
|
||||
}
|
||||
conferenceAvatarMap.forEach { (_, contactAvatarModel) ->
|
||||
contactAvatarModel.showTrust.postValue(showTrust)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getAvatarModelFromCache(key: String): ContactAvatarModel? {
|
||||
return knownContactsAvatarsMap[key] ?: unknownContactsAvatarsMap[key]
|
||||
}
|
||||
|
||||
interface ContactsListener {
|
||||
fun onContactsLoaded()
|
||||
|
||||
fun onContactFoundInRemoteDirectory(friend: Friend)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun Friend.getAvatarBitmap(round: Boolean = false): Bitmap? {
|
||||
try {
|
||||
return ImageUtils.getBitmap(
|
||||
coreContext.context,
|
||||
photo ?: getNativeContactPictureUri()?.toString(),
|
||||
round
|
||||
)
|
||||
} catch (_: NumberFormatException) {
|
||||
// Expected for contacts created by Linphone
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun Friend.getNativeContactPictureUri(): Uri? {
|
||||
val contactId = refKey
|
||||
if (contactId != null) {
|
||||
try {
|
||||
val lookupUri = ContentUris.withAppendedId(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
contactId.toLong()
|
||||
)
|
||||
|
||||
val pictureUri = Uri.withAppendedPath(
|
||||
lookupUri,
|
||||
ContactsContract.Contacts.Photo.DISPLAY_PHOTO
|
||||
)
|
||||
// Check that the URI points to a real file
|
||||
val contentResolver = coreContext.context.contentResolver
|
||||
try {
|
||||
val fd = contentResolver.openAssetFileDescriptor(pictureUri, "r")
|
||||
if (fd != null) {
|
||||
fd.close()
|
||||
return pictureUri
|
||||
}
|
||||
} catch (fnfe: FileNotFoundException) {
|
||||
Log.w("[Contacts Manager] Can't open [$pictureUri] for contact [$name]: $fnfe")
|
||||
} catch (e: Exception) {
|
||||
Log.e("[Contacts Manager] Can't open [$pictureUri] for contact [$name]: $e")
|
||||
}
|
||||
|
||||
// Fallback to thumbnail
|
||||
return Uri.withAppendedPath(
|
||||
lookupUri,
|
||||
ContactsContract.Contacts.Photo.CONTENT_DIRECTORY
|
||||
)
|
||||
} catch (_: NumberFormatException) {
|
||||
// Expected for contacts created by Linphone
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun Friend.getPerson(): Person {
|
||||
val personBuilder = Person.Builder()
|
||||
val personName = if (name.orEmpty().isNotEmpty()) {
|
||||
name
|
||||
} else {
|
||||
if (!lastName.isNullOrEmpty() || !firstName.isNullOrEmpty()) {
|
||||
Log.w("[Friend] Name is null or empty, using first and last name")
|
||||
"$firstName $lastName".trim()
|
||||
} else if (!organization.isNullOrEmpty()) {
|
||||
Log.w("[Friend] Name, first name & last name are null or empty, using organization instead")
|
||||
organization
|
||||
} else if (!jobTitle.isNullOrEmpty()) {
|
||||
Log.w("[Friend] Name, first and last names & organization are null or empty, using job title instead")
|
||||
jobTitle
|
||||
} else {
|
||||
Log.e("[Friend] No identification field filled for this friend!")
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
personBuilder.setName(personName.orEmpty().ifEmpty { "Unknown" })
|
||||
|
||||
val bm: Bitmap? = getAvatarBitmap()
|
||||
personBuilder.setIcon(
|
||||
if (bm == null) {
|
||||
Log.i(
|
||||
"[Friend] Can't use friend [$name] picture path, generating avatar based on initials"
|
||||
)
|
||||
AvatarGenerator(coreContext.context).setInitials(AppUtils.getInitials(personName.orEmpty())).buildIcon()
|
||||
} else {
|
||||
IconCompat.createWithAdaptiveBitmap(bm)
|
||||
}
|
||||
)
|
||||
|
||||
personBuilder.setKey(refKey)
|
||||
personBuilder.setUri(nativeUri)
|
||||
personBuilder.setImportant(true)
|
||||
return personBuilder.build()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun Friend.getListOfSipAddresses(): ArrayList<Address> {
|
||||
val addressesList = arrayListOf<Address>()
|
||||
if (corePreferences.hideSipAddresses) return addressesList
|
||||
|
||||
for (address in addresses) {
|
||||
if (addressesList.find { it.weakEqual(address) } == null) {
|
||||
addressesList.add(address)
|
||||
}
|
||||
}
|
||||
|
||||
return addressesList
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddressClickListener): ArrayList<ContactNumberOrAddressModel> {
|
||||
val addressesAndNumbers = arrayListOf<ContactNumberOrAddressModel>()
|
||||
|
||||
// Will return an empty list if corePreferences.hideSipAddresses == true
|
||||
for (address in getListOfSipAddresses()) {
|
||||
if (LinphoneUtils.isSipAddressLinkedToPhoneNumberByPresence(this, address.asStringUriOnly())) {
|
||||
continue
|
||||
}
|
||||
|
||||
val data = ContactNumberOrAddressModel(
|
||||
this,
|
||||
address,
|
||||
address.asStringUriOnly(),
|
||||
true, // SIP addresses are always enabled
|
||||
listener,
|
||||
true
|
||||
)
|
||||
addressesAndNumbers.add(data)
|
||||
}
|
||||
|
||||
if (corePreferences.hidePhoneNumbers) {
|
||||
return addressesAndNumbers
|
||||
}
|
||||
|
||||
for (number in phoneNumbersWithLabel) {
|
||||
val phoneNumber = number.phoneNumber
|
||||
val presenceModel = getPresenceModelForUriOrTel(phoneNumber)
|
||||
val hasPresenceInfo = !presenceModel?.contact.isNullOrEmpty()
|
||||
var presenceAddress: Address? = null
|
||||
|
||||
if (presenceModel != null && hasPresenceInfo) {
|
||||
val contact = presenceModel.contact
|
||||
if (!contact.isNullOrEmpty()) {
|
||||
val address = core.interpretUrl(contact, false)
|
||||
if (address != null) {
|
||||
address.clean() // To remove ;user=phone
|
||||
presenceAddress = address
|
||||
} else {
|
||||
Log.e("[Contacts Manager] Failed to parse phone number [$phoneNumber] contact address [$contact] from presence model!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// phone numbers are disabled is secure mode unless linked to a SIP address
|
||||
val defaultAccount = LinphoneUtils.getDefaultAccount()
|
||||
val enablePhoneNumbers = hasPresenceInfo || !isEndToEndEncryptionMandatory()
|
||||
val address = presenceAddress ?: core.interpretUrl(
|
||||
phoneNumber,
|
||||
LinphoneUtils.applyInternationalPrefix(defaultAccount)
|
||||
)
|
||||
address ?: continue
|
||||
|
||||
val label = PhoneNumberUtils.vcardParamStringToAddressBookLabel(
|
||||
coreContext.context.resources,
|
||||
number.label ?: ""
|
||||
)
|
||||
Log.d("[Contacts Manager] Parsed phone number [$phoneNumber] with label [$label] into address [${address.asStringUriOnly()}], presence address is [${presenceAddress?.asStringUriOnly()}]")
|
||||
val data = ContactNumberOrAddressModel(
|
||||
this,
|
||||
address,
|
||||
phoneNumber,
|
||||
enablePhoneNumbers,
|
||||
listener,
|
||||
false,
|
||||
label,
|
||||
presenceAddress != null
|
||||
)
|
||||
addressesAndNumbers.add(data)
|
||||
}
|
||||
|
||||
return addressesAndNumbers
|
||||
}
|
||||
46
app/src/main/java/org/linphone/core/BootReceiver.kt
Normal file
46
app/src/main/java/org/linphone/core/BootReceiver.kt
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.core
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.core.tools.Log
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "[Boot Receiver]"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val keepAlive = corePreferences.keepServiceAlive
|
||||
if (intent.action.equals(Intent.ACTION_BOOT_COMPLETED, ignoreCase = true)) {
|
||||
Log.i(
|
||||
"$TAG Device boot completed, keep alive service is ${if (keepAlive) "enabled" else "disabled"}"
|
||||
)
|
||||
} else if (intent.action.equals(Intent.ACTION_MY_PACKAGE_REPLACED, ignoreCase = true)) {
|
||||
Log.i(
|
||||
"$TAG App has been updated, keep alive service is ${if (keepAlive) "enabled" else "disabled"}"
|
||||
)
|
||||
}
|
||||
// Starting the keep alive service will be done by CoreContext directly
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
188
app/src/main/java/org/linphone/core/CoreFileTransferService.kt
Normal file
188
app/src/main/java/org/linphone/core/CoreFileTransferService.kt
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.core
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.compatibility.Compatibility
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.core.tools.service.FileTransferService
|
||||
import org.linphone.ui.main.MainActivity
|
||||
|
||||
@MainThread
|
||||
class CoreFileTransferService : FileTransferService() {
|
||||
companion object {
|
||||
private const val TAG = "[Core File Transfer Service]"
|
||||
}
|
||||
|
||||
var builder = NotificationCompat.Builder(this, SERVICE_NOTIFICATION_CHANNEL_ID)
|
||||
|
||||
var listenerAdded = false
|
||||
|
||||
private val coreListener = object : CoreListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onRemainingNumberOfFileTransferChanged(
|
||||
core: Core,
|
||||
downloadCount: Int,
|
||||
uploadCount: Int
|
||||
) {
|
||||
updateNotificationContent(downloadCount, uploadCount)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (!listenerAdded && coreContext.isCoreAvailable()) {
|
||||
coreContext.core.addListener(coreListener)
|
||||
listenerAdded = true
|
||||
}
|
||||
Log.i("$TAG Created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i("$TAG onStartCommand")
|
||||
if (!listenerAdded && coreContext.isCoreAvailable()) {
|
||||
coreContext.core.addListener(coreListener)
|
||||
listenerAdded = true
|
||||
}
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
Log.i("$TAG Task removed, doing nothing")
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i("$TAG onDestroy")
|
||||
coreContext.core.removeListener(coreListener)
|
||||
listenerAdded = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun createServiceNotification() {
|
||||
Log.i("$TAG Creating notification")
|
||||
|
||||
buildNotification()
|
||||
postNotification()
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
val downloadingFilesCount = core.remainingDownloadFileCount
|
||||
val uploadingFilesCount = core.remainingUploadFileCount
|
||||
updateNotificationContent(downloadingFilesCount, uploadingFilesCount)
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
private fun buildNotification() {
|
||||
val intent = Intent(applicationContext, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
mServiceNotification = builder.setContentTitle(
|
||||
getString(R.string.notification_file_transfer_title)
|
||||
)
|
||||
.setContentText(getString(R.string.notification_file_transfer_startup_message))
|
||||
.setSmallIcon(R.drawable.linphone_notification)
|
||||
.setAutoCancel(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setShowWhen(false)
|
||||
.setOngoing(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateNotificationContent(downloadingFilesCount: Int, uploadingFilesCount: Int) {
|
||||
Log.i(
|
||||
"$TAG [$downloadingFilesCount] file(s) being downloaded, [$uploadingFilesCount] file(s) being uploaded"
|
||||
)
|
||||
if (downloadingFilesCount == 0 && uploadingFilesCount == 0) {
|
||||
Log.i("$TAG No more files being transferred, do not alter the notification")
|
||||
return
|
||||
}
|
||||
|
||||
val downloadText = resources.getQuantityString(
|
||||
R.plurals.notification_file_transfer_download,
|
||||
downloadingFilesCount,
|
||||
"$downloadingFilesCount"
|
||||
)
|
||||
val uploadText = resources.getQuantityString(
|
||||
R.plurals.notification_file_transfer_upload,
|
||||
uploadingFilesCount,
|
||||
"$uploadingFilesCount"
|
||||
)
|
||||
|
||||
val message = if (downloadingFilesCount > 0 && uploadingFilesCount > 0) {
|
||||
getString(
|
||||
R.string.notification_file_transfer_upload_download_message,
|
||||
downloadText,
|
||||
uploadText
|
||||
)
|
||||
} else if (downloadingFilesCount > 0) {
|
||||
downloadText
|
||||
} else {
|
||||
uploadText
|
||||
}
|
||||
|
||||
if (mServiceNotification == null) {
|
||||
buildNotification()
|
||||
}
|
||||
mServiceNotification = builder.setContentText(message).build()
|
||||
postNotification()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@AnyThread
|
||||
private fun postNotification() {
|
||||
val notificationsManager = NotificationManagerCompat.from(this)
|
||||
if (Compatibility.isPostNotificationsPermissionGranted(this)) {
|
||||
if (mServiceNotification != null) {
|
||||
Log.i("$TAG Sending notification to manager")
|
||||
notificationsManager.notify(SERVICE_NOTIF_ID, mServiceNotification)
|
||||
} else {
|
||||
Log.e("$TAG Notification content hasn't been computed yet!")
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG POST_NOTIFICATIONS permission wasn't granted!")
|
||||
}
|
||||
}
|
||||
}
|
||||
79
app/src/main/java/org/linphone/core/CoreInCallService.kt
Normal file
79
app/src/main/java/org/linphone/core/CoreInCallService.kt
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.core
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.MainThread
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.core.tools.service.CoreService
|
||||
|
||||
@MainThread
|
||||
class CoreInCallService : CoreService() {
|
||||
companion object {
|
||||
private const val TAG = "[Core InCall Service]"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i("$TAG Created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i("$TAG onStartCommand")
|
||||
coreContext.notificationsManager.onInCallServiceStarted(this)
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
Log.i("$TAG Task removed, doing nothing")
|
||||
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i("$TAG onDestroy")
|
||||
coreContext.notificationsManager.onInCallServiceDestroyed()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun createServiceNotificationChannel() {
|
||||
// Do nothing, app's Notifications Manager will do the job
|
||||
}
|
||||
|
||||
override fun createServiceNotification() {
|
||||
// Do nothing, app's Notifications Manager will do the job
|
||||
}
|
||||
|
||||
override fun showForegroundServiceNotification(isVideoCall: Boolean) {
|
||||
// Do nothing, app's Notifications Manager will do the job
|
||||
}
|
||||
|
||||
override fun hideForegroundServiceNotification() {
|
||||
// Do nothing, app's Notifications Manager will do the job
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.core
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.MainThread
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.tools.Log
|
||||
|
||||
@MainThread
|
||||
class CoreKeepAliveThirdPartyAccountsService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "[Core Keep Alive Third Party Accounts Service]"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i("$TAG Created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i("$TAG onStartCommand")
|
||||
coreContext.notificationsManager.onKeepAliveServiceStarted(this)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
Log.i("$TAG Task removed, doing nothing")
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i("$TAG onDestroy")
|
||||
coreContext.notificationsManager.onKeepAliveServiceDestroyed()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -20,62 +20,521 @@
|
|||
package org.linphone.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.linphone.BuildConfig
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.contacts.ContactLoader.Companion.LINPHONE_ADDRESS_BOOK_FRIEND_LIST
|
||||
|
||||
class CorePreferences
|
||||
@UiThread
|
||||
constructor(private val context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "[Preferences]"
|
||||
|
||||
const val CONFIG_FILE_NAME = ".linphonerc"
|
||||
}
|
||||
|
||||
class CorePreferences constructor(private val context: Context) {
|
||||
private var _config: Config? = null
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var config: Config
|
||||
get() = _config ?: coreContext.core.config
|
||||
set(value) {
|
||||
_config = value
|
||||
}
|
||||
|
||||
fun chatRoomMuted(id: String): Boolean {
|
||||
val sharedPreferences: SharedPreferences = coreContext.context.getSharedPreferences(
|
||||
"notifications",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
return sharedPreferences.getBoolean(id, false)
|
||||
}
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var printLogsInLogcat: Boolean
|
||||
get() = config.getBool("app", "debug", BuildConfig.DEBUG)
|
||||
set(value) {
|
||||
config.setBool("app", "debug", value)
|
||||
}
|
||||
|
||||
fun muteChatRoom(id: String, mute: Boolean) {
|
||||
val sharedPreferences: SharedPreferences = coreContext.context.getSharedPreferences(
|
||||
"notifications",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val editor = sharedPreferences.edit()
|
||||
editor.putBoolean(id, mute)
|
||||
editor.apply()
|
||||
}
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var sendLogsToCrashlytics: Boolean
|
||||
get() = config.getBool("app", "send_logs_to_crashlytics", BuildConfig.CRASHLYTICS_ENABLED)
|
||||
set(value) {
|
||||
config.setBool("app", "send_logs_to_crashlytics", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var firstLaunch: Boolean
|
||||
get() = config.getBool("app", "first_6.0_launch", true)
|
||||
set(value) {
|
||||
config.setBool("app", "first_6.0_launch", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var linphoneConfigurationVersion: Int
|
||||
get() = config.getInt("app", "config_version", 52005)
|
||||
set(value) {
|
||||
config.setInt("app", "config_version", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var autoStart: Boolean
|
||||
get() = config.getBool("app", "auto_start", true)
|
||||
set(value) {
|
||||
config.setBool("app", "auto_start", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var checkForUpdateServerUrl: String
|
||||
get() = config.getString("misc", "version_check_url_root", "").orEmpty()
|
||||
set(value) {
|
||||
config.setString("misc", "version_check_url_root", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var conditionsAndPrivacyPolicyAccepted: Boolean
|
||||
get() = config.getBool("app", "read_and_agree_terms_and_privacy", false)
|
||||
set(value) {
|
||||
config.setBool("app", "read_and_agree_terms_and_privacy", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var publishPresence: Boolean
|
||||
get() = config.getBool("app", "publish_presence", true)
|
||||
set(value) {
|
||||
config.setBool("app", "publish_presence", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var keepServiceAlive: Boolean
|
||||
get() = config.getBool("app", "keep_service_alive", false)
|
||||
set(value) {
|
||||
config.setBool("app", "keep_service_alive", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var deviceName: String
|
||||
get() = config.getString("app", "device", "").orEmpty().trim()
|
||||
set(value) {
|
||||
config.setString("app", "device", value.trim())
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var showDeveloperSettings: Boolean
|
||||
get() = config.getBool("ui", "show_developer_settings", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "show_developer_settings", value)
|
||||
}
|
||||
|
||||
// Call settings
|
||||
|
||||
// This won't be done if bluetooth or wired headset is used
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var routeAudioToBluetoothWhenPossible: Boolean
|
||||
get() = config.getBool("app", "route_audio_to_bluetooth_when_possible", true)
|
||||
set(value) {
|
||||
config.setBool("app", "route_audio_to_bluetooth_when_possible", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var routeAudioToSpeakerWhenVideoIsEnabled: Boolean
|
||||
get() = config.getBool("app", "route_audio_to_speaker_when_video_enabled", true)
|
||||
set(value) {
|
||||
config.setBool("app", "route_audio_to_speaker_when_video_enabled", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var callRecordingUseSmffFormat: Boolean
|
||||
get() = config.getBool("app", "use_smff_for_call_recording", false)
|
||||
set(value) {
|
||||
config.setBool("app", "use_smff_for_call_recording", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var automaticallyStartCallRecording: Boolean
|
||||
get() = config.getBool("app", "auto_start_call_record", false)
|
||||
set(value) {
|
||||
config.setBool("app", "auto_start_call_record", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var showDialogWhenCallingDeviceUuidDirectly: Boolean
|
||||
get() = config.getBool("app", "show_confirmation_dialog_zrtp_trust_call", true)
|
||||
set(value) {
|
||||
config.setBool("app", "show_confirmation_dialog_zrtp_trust_call", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var acceptEarlyMedia: Boolean
|
||||
get() = config.getBool("sip", "incoming_calls_early_media", false)
|
||||
set(value) {
|
||||
config.setBool("sip", "incoming_calls_early_media", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var allowOutgoingEarlyMedia: Boolean
|
||||
get() = config.getBool("misc", "real_early_media", false)
|
||||
set(value) {
|
||||
config.setBool("misc", "real_early_media", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var autoAnswerEnabled: Boolean
|
||||
get() = config.getBool("app", "auto_answer", false)
|
||||
set(value) {
|
||||
config.setBool("app", "auto_answer", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var autoAnswerDelay: Int
|
||||
get() = config.getInt("app", "auto_answer_delay", 0)
|
||||
set(value) {
|
||||
config.setInt("app", "auto_answer_delay", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var autoAnswerVideoCallsWithVideoDirectionSendReceive: Boolean
|
||||
get() = config.getBool("app", "auto_answer_video_send_receive", false)
|
||||
set(value) {
|
||||
config.setBool("app", "auto_answer_video_send_receive", value)
|
||||
}
|
||||
|
||||
// Conversation related
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var markConversationAsReadWhenDismissingMessageNotification: Boolean
|
||||
get() = config.getBool("app", "mark_as_read_notif_dismissal", false)
|
||||
set(value) {
|
||||
config.setBool("app", "mark_as_read_notif_dismissal", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var makePublicMediaFilesDownloaded: Boolean
|
||||
// Keep old name for backward compatibility
|
||||
get() = config.getBool("app", "make_downloaded_images_public_in_gallery", false)
|
||||
set(value) {
|
||||
config.setBool("app", "make_downloaded_images_public_in_gallery", value)
|
||||
}
|
||||
|
||||
// Conference related
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var createEndToEndEncryptedMeetingsAndGroupCalls: Boolean
|
||||
get() = config.getBool("app", "create_e2e_encrypted_conferences", false)
|
||||
set(value) {
|
||||
config.setBool("app", "create_e2e_encrypted_conferences", value)
|
||||
}
|
||||
|
||||
// Contacts related
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var sortContactsByFirstName: Boolean
|
||||
get() = config.getBool("ui", "sort_contacts_by_first_name", true) // If disabled, last name will be used
|
||||
set(value) {
|
||||
config.setBool("ui", "sort_contacts_by_first_name", value)
|
||||
}
|
||||
|
||||
@get:AnyThread
|
||||
var hideContactsWithoutPhoneNumberOrSipAddress: Boolean
|
||||
get() = config.getBool("ui", "hide_contacts_without_phone_number_or_sip_address", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "hide_contacts_without_phone_number_or_sip_address", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var contactsFilter: String
|
||||
get() = config.getString("ui", "contacts_filter", "")!! // Default value must be empty!
|
||||
set(value) {
|
||||
config.setString("ui", "contacts_filter", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var showFavoriteContacts: Boolean
|
||||
get() = config.getBool("ui", "show_favorites_contacts", true)
|
||||
set(value) {
|
||||
config.setBool("ui", "show_favorites_contacts", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var friendListInWhichStoreNewlyCreatedFriends: String
|
||||
get() = config.getString(
|
||||
"app",
|
||||
"friend_list_to_store_newly_created_contacts",
|
||||
LINPHONE_ADDRESS_BOOK_FRIEND_LIST
|
||||
)!!
|
||||
set(value) {
|
||||
config.setString("app", "friend_list_to_store_newly_created_contacts", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var editNativeContactsInLinphone: Boolean
|
||||
get() = config.getBool("ui", "edit_native_contact_in_linphone", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "edit_native_contact_in_linphone", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var disableAddContact: Boolean
|
||||
get() = config.getBool("ui", "disable_add_contact", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "disable_add_contact", value)
|
||||
}
|
||||
|
||||
// Voice recordings related
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var voiceRecordingMaxDuration: Int
|
||||
get() = config.getInt("app", "voice_recording_max_duration", 600000) // in ms
|
||||
set(value) = config.setInt("app", "voice_recording_max_duration", value)
|
||||
|
||||
// User interface related
|
||||
|
||||
// -1 means auto, 0 no, 1 yes
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var darkMode: Int
|
||||
get() {
|
||||
if (!darkModeAllowed) return 0
|
||||
return config.getInt("app", "dark_mode", -1)
|
||||
}
|
||||
set(value) {
|
||||
config.setInt("app", "dark_mode", value)
|
||||
}
|
||||
|
||||
// Allows to make screenshots
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var enableSecureMode: Boolean
|
||||
get() = config.getBool("ui", "enable_secure_mode", true)
|
||||
set(value) {
|
||||
config.setBool("ui", "enable_secure_mode", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var automaticallyShowDialpad: Boolean
|
||||
get() = config.getBool("ui", "automatically_show_dialpad", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "automatically_show_dialpad", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var themeMainColor: String
|
||||
get() = config.getString("ui", "theme_main_color", "orange")!!
|
||||
set(value) {
|
||||
config.setString("ui", "theme_main_color", value)
|
||||
}
|
||||
|
||||
// Customization options
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var showMicrophoneAndSpeakerVuMeters: Boolean
|
||||
get() = config.getBool("ui", "show_mic_speaker_vu_meter", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "show_mic_speaker_vu_meter", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var pushNotificationCompatibleDomains: Array<String>
|
||||
get() = config.getStringList("app", "push_notification_domains", arrayOf("sip.linphone.org"))
|
||||
set(value) {
|
||||
config.setStringList("app", "push_notification_domains", value)
|
||||
}
|
||||
|
||||
@get:AnyThread
|
||||
val defaultDomain: String
|
||||
get() = config.getString("app", "default_domain", "sip.linphone.org")!!
|
||||
|
||||
@get:AnyThread
|
||||
val darkModeAllowed: Boolean
|
||||
get() = config.getBool("ui", "dark_mode_allowed", true)
|
||||
|
||||
@get:AnyThread
|
||||
val changeMainColorAllowed: Boolean
|
||||
get() = config.getBool("ui", "change_main_color_allowed", false)
|
||||
|
||||
@get:AnyThread
|
||||
val onlyDisplaySipUriUsername: Boolean
|
||||
get() = config.getBool("ui", "only_display_sip_uri_username", false)
|
||||
|
||||
@get:AnyThread
|
||||
val hideSipAddresses: Boolean
|
||||
get() = config.getBool("ui", "hide_sip_addresses", false)
|
||||
|
||||
@get:AnyThread
|
||||
val disableChat: Boolean
|
||||
get() = config.getBool("ui", "disable_chat_feature", false)
|
||||
|
||||
@get:AnyThread
|
||||
val disableMeetings: Boolean
|
||||
get() = config.getBool("ui", "disable_meetings_feature", false)
|
||||
|
||||
@get:AnyThread
|
||||
val disableBroadcasts: Boolean
|
||||
get() = config.getBool("ui", "disable_broadcast_feature", true) // TODO FIXME: not implemented yet
|
||||
|
||||
@get:AnyThread
|
||||
val disableCallRecordings: Boolean
|
||||
get() = config.getBool("ui", "disable_call_recordings_feature", false)
|
||||
|
||||
@get:AnyThread
|
||||
val maxAccountsCount: Int
|
||||
get() = config.getInt("ui", "max_account", 0) // 0 means no max
|
||||
|
||||
@get:AnyThread
|
||||
val hidePhoneNumbers: Boolean
|
||||
get() = config.getBool("ui", "hide_phone_numbers", false)
|
||||
|
||||
@get:AnyThread
|
||||
val hideSettings: Boolean
|
||||
get() = config.getBool("ui", "hide_settings", false)
|
||||
|
||||
@get:AnyThread
|
||||
val hideAccountSettings: Boolean
|
||||
get() = config.getBool("ui", "hide_account_settings", false)
|
||||
|
||||
@get:AnyThread
|
||||
val hideAdvancedSettings: Boolean
|
||||
get() = config.getBool("ui", "hide_advanced_settings", false)
|
||||
|
||||
@get:AnyThread
|
||||
val hideAssistantCreateAccount: Boolean
|
||||
get() = config.getBool("ui", "assistant_hide_create_account", false)
|
||||
|
||||
@get:AnyThread
|
||||
val hideAssistantScanQrCode: Boolean
|
||||
get() = config.getBool("ui", "assistant_disable_qr_code", false)
|
||||
|
||||
@get:AnyThread
|
||||
val hideAssistantThirdPartySipAccount: Boolean
|
||||
get() = config.getBool("ui", "assistant_hide_third_party_account", false)
|
||||
|
||||
@get:AnyThread
|
||||
val magicSearchResultsLimit: Int
|
||||
get() = config.getInt("ui", "max_number_of_magic_search_results", 300)
|
||||
|
||||
@get:AnyThread
|
||||
val singleSignOnClientId: String
|
||||
get() = config.getString("app", "oidc_client_id", "linphone")!!
|
||||
|
||||
@get:AnyThread
|
||||
val useUsernameAsSingleSignOnLoginHint: Boolean
|
||||
get() = config.getBool("ui", "use_username_as_sso_login_hint", false)
|
||||
|
||||
@get:AnyThread
|
||||
val thirdPartySipAccountDefaultTransport: String
|
||||
get() = config.getString("ui", "assistant_third_party_sip_account_transport", "tls")!!
|
||||
|
||||
@get:AnyThread
|
||||
val thirdPartySipAccountDefaultDomain: String
|
||||
get() = config.getString("ui", "assistant_third_party_sip_account_domain", "")!!
|
||||
|
||||
@get:AnyThread
|
||||
val assistantDirectlyGoToThirdPartySipAccountLogin: Boolean
|
||||
get() = config.getBool(
|
||||
"ui",
|
||||
"assistant_go_directly_to_third_party_sip_account_login",
|
||||
false
|
||||
)
|
||||
|
||||
@get:AnyThread
|
||||
val fetchContactsFromDefaultDirectory: Boolean
|
||||
get() = config.getBool("app", "fetch_contacts_from_default_directory", true)
|
||||
|
||||
@get:AnyThread
|
||||
val showLettersOnDialpad: Boolean
|
||||
get() = config.getBool("ui", "show_letters_on_dialpad", true)
|
||||
|
||||
// Paths
|
||||
|
||||
@get:AnyThread
|
||||
val configPath: String
|
||||
get() = context.filesDir.absolutePath + "/.linphonerc"
|
||||
get() = context.filesDir.absolutePath + "/" + CONFIG_FILE_NAME
|
||||
|
||||
@get:AnyThread
|
||||
val factoryConfigPath: String
|
||||
get() = context.filesDir.absolutePath + "/linphonerc"
|
||||
|
||||
@get:AnyThread
|
||||
val linphoneDefaultValuesPath: String
|
||||
get() = context.filesDir.absolutePath + "/assistant_linphone_default_values"
|
||||
|
||||
@get:AnyThread
|
||||
val thirdPartyDefaultValuesPath: String
|
||||
get() = context.filesDir.absolutePath + "/assistant_third_party_default_values"
|
||||
|
||||
@get:AnyThread
|
||||
val vfsCachePath: String
|
||||
get() = context.cacheDir.absolutePath + "/evfs/"
|
||||
|
||||
@get:AnyThread
|
||||
val ssoCacheFile: String
|
||||
get() = context.filesDir.absolutePath + "/auth_state.json"
|
||||
|
||||
@get:AnyThread
|
||||
val messageReceivedInVisibleConversationNotificationSound: String
|
||||
get() = context.filesDir.absolutePath + "/share/sounds/linphone/incoming_chat.wav"
|
||||
|
||||
@UiThread
|
||||
fun copyAssetsFromPackage() {
|
||||
copy("linphonerc_default", configPath)
|
||||
copy("linphonerc_factory", factoryConfigPath, true)
|
||||
copy("assistant_linphone_default_values", linphoneDefaultValuesPath, true)
|
||||
copy("assistant_third_party_default_values", thirdPartyDefaultValuesPath, true)
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun clearPreviousGrammars() {
|
||||
val cpimGrammar = File("${context.filesDir.absolutePath}/share/belr/grammars/cpim_grammar")
|
||||
if (cpimGrammar.exists()) {
|
||||
cpimGrammar.delete()
|
||||
}
|
||||
val icsGrammar = File("${context.filesDir.absolutePath}/share/belr/grammars/ics_grammar")
|
||||
if (icsGrammar.exists()) {
|
||||
icsGrammar.delete()
|
||||
}
|
||||
val identityGrammar = File(
|
||||
"${context.filesDir.absolutePath}/share/belr/grammars/identity_grammar"
|
||||
)
|
||||
if (identityGrammar.exists()) {
|
||||
identityGrammar.delete()
|
||||
}
|
||||
val mwiGrammar = File("${context.filesDir.absolutePath}/share/belr/grammars/mwi_grammar")
|
||||
if (mwiGrammar.exists()) {
|
||||
mwiGrammar.delete()
|
||||
}
|
||||
val sdpGrammar = File("${context.filesDir.absolutePath}/share/belr/grammars/sdp_grammar")
|
||||
if (sdpGrammar.exists()) {
|
||||
sdpGrammar.delete()
|
||||
}
|
||||
val sipGrammar = File("${context.filesDir.absolutePath}/share/belr/grammars/sip_grammar")
|
||||
if (sipGrammar.exists()) {
|
||||
sipGrammar.delete()
|
||||
}
|
||||
val vcard3Grammar = File(
|
||||
"${context.filesDir.absolutePath}/share/belr/grammars/vcard3_grammar"
|
||||
)
|
||||
if (vcard3Grammar.exists()) {
|
||||
vcard3Grammar.delete()
|
||||
}
|
||||
val vcardGrammar = File(
|
||||
"${context.filesDir.absolutePath}/share/belr/grammars/vcard_grammar"
|
||||
)
|
||||
if (vcardGrammar.exists()) {
|
||||
vcardGrammar.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
private fun copy(from: String, to: String, overrideIfExists: Boolean = false) {
|
||||
val outFile = File(to)
|
||||
if (outFile.exists()) {
|
||||
if (!overrideIfExists) {
|
||||
android.util.Log.i(
|
||||
context.getString(org.linphone.R.string.app_name),
|
||||
"[Preferences] File $to already exists"
|
||||
"$TAG File $to already exists"
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
android.util.Log.i(
|
||||
context.getString(org.linphone.R.string.app_name),
|
||||
"[Preferences] Overriding $to by $from asset"
|
||||
"$TAG Overriding $to by $from asset"
|
||||
)
|
||||
|
||||
val outStream = FileOutputStream(outFile)
|
||||
|
|
|
|||
90
app/src/main/java/org/linphone/core/CorePushService.kt
Normal file
90
app/src/main/java/org/linphone/core/CorePushService.kt
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.core
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.app.NotificationCompat
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.core.tools.service.PushService
|
||||
import org.linphone.ui.main.MainActivity
|
||||
|
||||
@MainThread
|
||||
class CorePushService : PushService() {
|
||||
companion object {
|
||||
private const val TAG = "[Core Push Service]"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i("$TAG Created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i("$TAG onStartCommand")
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
Log.i("$TAG Task removed, doing nothing")
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i("$TAG onDestroy")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun createServiceNotification() {
|
||||
Log.i("$TAG Creating notification")
|
||||
|
||||
val intent = Intent(applicationContext, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
mServiceNotification = NotificationCompat.Builder(
|
||||
this,
|
||||
SERVICE_NOTIFICATION_CHANNEL_ID
|
||||
)
|
||||
.setContentTitle(getString(R.string.notification_push_received_title))
|
||||
.setContentText(getString(R.string.notification_push_received_message))
|
||||
.setSmallIcon(R.drawable.linphone_notification)
|
||||
.setAutoCancel(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setShowWhen(false)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
224
app/src/main/java/org/linphone/core/VFS.kt
Normal file
224
app/src/main/java/org/linphone/core/VFS.kt
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import android.util.Pair
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import java.math.BigInteger
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.KeyStore
|
||||
import java.security.KeyStoreException
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import org.linphone.core.tools.Log
|
||||
import androidx.core.content.edit
|
||||
|
||||
class VFS {
|
||||
companion object {
|
||||
private const val TAG = "[VFS]"
|
||||
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||
private const val ALIAS = "vfs"
|
||||
private const val LINPHONE_VFS_ENCRYPTION_AES256GCM128_SHA256 = 2
|
||||
private const val VFS_IV = "vfsiv"
|
||||
private const val VFS_KEY = "vfskey"
|
||||
private const val ENCRYPTED_SHARED_PREFS_FILE = "encrypted.pref"
|
||||
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
val preferences = getEncryptedSharedPreferences(context)
|
||||
if (preferences == null) {
|
||||
Log.e("$TAG Failed to get encrypted shared preferences!")
|
||||
return false
|
||||
}
|
||||
return preferences.getBoolean("vfs_enabled", false)
|
||||
}
|
||||
|
||||
fun enable(context: Context): Boolean {
|
||||
val preferences = getEncryptedSharedPreferences(context)
|
||||
if (preferences == null) {
|
||||
Log.e("$TAG Failed to get encrypted shared preferences, VFS won't be enabled!")
|
||||
return false
|
||||
}
|
||||
|
||||
if (preferences.getBoolean("vfs_enabled", false)) {
|
||||
Log.w("$TAG VFS is already enabled, skipping...")
|
||||
return false
|
||||
}
|
||||
|
||||
preferences.edit { putBoolean("vfs_enabled", true) }
|
||||
|
||||
if (corePreferences.makePublicMediaFilesDownloaded) {
|
||||
Log.w("$TAG VFS is now enabled, disabling auto export of media files to native gallery")
|
||||
corePreferences.makePublicMediaFilesDownloaded = false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun setup(context: Context) {
|
||||
// Use Android logger as our isn't ready yet
|
||||
try {
|
||||
android.util.Log.i(TAG, "$TAG Initializing...")
|
||||
val preferences = getEncryptedSharedPreferences(context)
|
||||
if (preferences == null) {
|
||||
Log.e("$TAG Failed to get encrypted shared preferences, can't initialize VFS!")
|
||||
return
|
||||
}
|
||||
|
||||
if (preferences.getString(VFS_IV, null) == null) {
|
||||
android.util.Log.i(TAG, "$TAG No initialization vector found, generating it")
|
||||
generateSecretKey()
|
||||
encryptToken(generateToken()).let { data ->
|
||||
preferences
|
||||
.edit(commit = true) {
|
||||
putString(VFS_IV, data.first)
|
||||
.putString(VFS_KEY, data.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Factory.instance().setVfsEncryption(
|
||||
LINPHONE_VFS_ENCRYPTION_AES256GCM128_SHA256,
|
||||
getVfsKey(preferences).toByteArray().copyOfRange(0, 32),
|
||||
32
|
||||
)
|
||||
|
||||
android.util.Log.i(TAG, "$TAG Initialized")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.wtf(TAG, "$TAG Unable to activate VFS encryption: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEncryptedSharedPreferences(context: Context): SharedPreferences? {
|
||||
return try {
|
||||
val masterKey: MasterKey = MasterKey.Builder(
|
||||
context,
|
||||
MasterKey.DEFAULT_MASTER_KEY_ALIAS
|
||||
).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
ENCRYPTED_SHARED_PREFS_FILE,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
} catch (kse: KeyStoreException) {
|
||||
Log.e("[VFS] Keystore exception: $kse")
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e("[VFS] Exception: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(java.lang.Exception::class)
|
||||
private fun generateSecretKey() {
|
||||
val keyGenerator =
|
||||
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
|
||||
keyGenerator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.build()
|
||||
)
|
||||
keyGenerator.generateKey()
|
||||
}
|
||||
|
||||
@Throws(java.lang.Exception::class)
|
||||
private fun getSecretKey(): SecretKey? {
|
||||
val ks = KeyStore.getInstance(ANDROID_KEY_STORE)
|
||||
ks.load(null)
|
||||
val entry = ks.getEntry(ALIAS, null) as KeyStore.SecretKeyEntry
|
||||
return entry.secretKey
|
||||
}
|
||||
|
||||
@Throws(java.lang.Exception::class)
|
||||
private fun generateToken(): String {
|
||||
return sha512(UUID.randomUUID().toString())
|
||||
}
|
||||
|
||||
@Throws(java.lang.Exception::class)
|
||||
private fun encryptData(textToEncrypt: String): Pair<ByteArray, ByteArray> {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
|
||||
val iv = cipher.iv
|
||||
return Pair(
|
||||
iv,
|
||||
cipher.doFinal(textToEncrypt.toByteArray(StandardCharsets.UTF_8))
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(java.lang.Exception::class)
|
||||
private fun decryptData(encrypted: String?, encryptionIv: ByteArray): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(128, encryptionIv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
|
||||
val encryptedData = Base64.decode(encrypted, Base64.DEFAULT)
|
||||
return String(cipher.doFinal(encryptedData), StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
@Throws(java.lang.Exception::class)
|
||||
private fun encryptToken(token: String): Pair<String?, String?> {
|
||||
val encryptedData = encryptData(token)
|
||||
return Pair(
|
||||
Base64.encodeToString(encryptedData.first, Base64.DEFAULT),
|
||||
Base64.encodeToString(encryptedData.second, Base64.DEFAULT)
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(java.lang.Exception::class)
|
||||
private fun sha512(input: String): String {
|
||||
val md = MessageDigest.getInstance("SHA-512")
|
||||
val messageDigest = md.digest(input.toByteArray())
|
||||
val no = BigInteger(1, messageDigest)
|
||||
var hashtext = no.toString(16)
|
||||
while (hashtext.length < 32) {
|
||||
hashtext = "0$hashtext"
|
||||
}
|
||||
return hashtext
|
||||
}
|
||||
|
||||
@Throws(java.lang.Exception::class)
|
||||
private fun getVfsKey(sharedPreferences: SharedPreferences): String {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE)
|
||||
keyStore.load(null)
|
||||
return decryptData(
|
||||
sharedPreferences.getString(VFS_KEY, null),
|
||||
Base64.decode(sharedPreferences.getString(VFS_IV, null), Base64.DEFAULT)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.RemoteInput
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.AudioDevice
|
||||
import org.linphone.core.ConferenceParams
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.AudioUtils
|
||||
|
||||
class NotificationBroadcastReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "[Notification Broadcast Receiver]"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationId = intent.getIntExtra(NotificationsManager.INTENT_NOTIF_ID, 0)
|
||||
val action = intent.action
|
||||
Log.i("$TAG Got notification broadcast for ID [$notificationId] with action [$action]")
|
||||
|
||||
// Wait for coreContext to be ready to handle intent
|
||||
while (!coreContext.isReady()) {
|
||||
Thread.sleep(50)
|
||||
}
|
||||
|
||||
if (
|
||||
action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION ||
|
||||
action == NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION ||
|
||||
action == NotificationsManager.INTENT_TOGGLE_SPEAKER_CALL_NOTIF_ACTION
|
||||
) {
|
||||
handleCallIntent(intent, notificationId, action)
|
||||
} else if (
|
||||
action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION ||
|
||||
action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION
|
||||
) {
|
||||
handleChatIntent(context, intent, notificationId, action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCallIntent(intent: Intent, notificationId: Int, action: String) {
|
||||
val remoteSipUri = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_SIP_URI)
|
||||
if (remoteSipUri == null) {
|
||||
Log.e("$TAG Remote SIP address is null for call notification ID [$notificationId]")
|
||||
return
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
val call = core.calls.find {
|
||||
it.remoteAddress.asStringUriOnly() == remoteSipUri
|
||||
}
|
||||
if (call == null) {
|
||||
Log.e("$TAG Couldn't find call from remote address [$remoteSipUri]")
|
||||
} else {
|
||||
when (action) {
|
||||
NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION -> {
|
||||
Log.i("$TAG Answering call with remote address [$remoteSipUri]")
|
||||
coreContext.answerCall(call)
|
||||
}
|
||||
NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION -> {
|
||||
Log.i("$TAG Declining/terminating call with remote address [$remoteSipUri]")
|
||||
coreContext.terminateCall(call)
|
||||
}
|
||||
NotificationsManager.INTENT_TOGGLE_SPEAKER_CALL_NOTIF_ACTION -> {
|
||||
val audioDevice = call.outputAudioDevice
|
||||
val isUsingSpeaker = audioDevice?.type == AudioDevice.Type.Speaker
|
||||
if (isUsingSpeaker) {
|
||||
Log.i("$TAG Routing audio to earpiece for call [$remoteSipUri]")
|
||||
AudioUtils.routeAudioToEarpiece(call)
|
||||
} else {
|
||||
Log.i("$TAG Routing audio to speaker for call [$remoteSipUri]")
|
||||
AudioUtils.routeAudioToSpeaker(call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChatIntent(context: Context, intent: Intent, notificationId: Int, action: String) {
|
||||
val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_SIP_URI)
|
||||
if (remoteSipAddress == null) {
|
||||
Log.e("$TAG Remote SIP address is null for notification ID [$notificationId]")
|
||||
return
|
||||
}
|
||||
val localIdentity = intent.getStringExtra(NotificationsManager.INTENT_LOCAL_IDENTITY)
|
||||
if (localIdentity == null) {
|
||||
Log.e("$TAG Local identity is null for notification ID [$notificationId]")
|
||||
return
|
||||
}
|
||||
|
||||
val reply = getMessageText(intent)?.toString()
|
||||
if (action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) {
|
||||
if (reply == null) {
|
||||
Log.e("$TAG Couldn't get reply text")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
val remoteAddress = core.interpretUrl(remoteSipAddress, false)
|
||||
if (remoteAddress == null) {
|
||||
Log.e(
|
||||
"$TAG Couldn't interpret remote address [$remoteSipAddress]"
|
||||
)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
val localAddress = core.interpretUrl(localIdentity, false)
|
||||
if (localAddress == null) {
|
||||
Log.e(
|
||||
"$TAG Couldn't interpret local address [$localIdentity]"
|
||||
)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
val params: ConferenceParams? = null
|
||||
val room = core.searchChatRoom(
|
||||
params,
|
||||
localAddress,
|
||||
remoteAddress,
|
||||
arrayOfNulls<Address>(
|
||||
0
|
||||
)
|
||||
)
|
||||
if (room == null) {
|
||||
Log.e(
|
||||
"$TAG Couldn't find conversation for remote address [$remoteSipAddress] and local address [$localIdentity]"
|
||||
)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
if (action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) {
|
||||
val msg = room.createMessageFromUtf8(reply)
|
||||
msg.userData = notificationId
|
||||
msg.addListener(coreContext.notificationsManager.chatMessageListener)
|
||||
msg.send()
|
||||
Log.i("$TAG Reply sent for notif id [$notificationId]")
|
||||
} else if (action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION) {
|
||||
Log.i("$TAG Marking chat room from notification id [$notificationId] as read")
|
||||
room.markAsRead()
|
||||
if (!coreContext.notificationsManager.dismissChatNotification(room)) {
|
||||
Log.w(
|
||||
"$TAG Notifications Manager failed to cancel notification"
|
||||
)
|
||||
val notificationManager = context.getSystemService(
|
||||
NotificationManager::class.java
|
||||
)
|
||||
notificationManager.cancel(NotificationsManager.CHAT_TAG, notificationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMessageText(intent: Intent): CharSequence? {
|
||||
val remoteInput = RemoteInput.getResultsFromIntent(intent)
|
||||
return remoteInput?.getCharSequence(NotificationsManager.KEY_TEXT_REPLY)
|
||||
}
|
||||
}
|
||||
2038
app/src/main/java/org/linphone/notifications/NotificationsManager.kt
Normal file
2038
app/src/main/java/org/linphone/notifications/NotificationsManager.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,251 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.telecom
|
||||
|
||||
import android.telecom.DisconnectCause
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.telecom.CallAttributesCompat
|
||||
import androidx.core.telecom.CallControlResult
|
||||
import androidx.core.telecom.CallControlScope
|
||||
import androidx.core.telecom.CallEndpointCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.CallListenerStub
|
||||
import org.linphone.core.Reason
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.AudioUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
class TelecomCallControlCallback(
|
||||
private val call: Call,
|
||||
private val callControl: CallControlScope,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "[Telecom Call Control Callback]"
|
||||
}
|
||||
|
||||
private var mutedByTelecomManager = false
|
||||
|
||||
private val callListener = object : CallListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onStateChanged(call: Call, state: Call.State?, message: String) {
|
||||
Log.i("$TAG Call [${call.remoteAddress.asStringUriOnly()}] state changed [$state]")
|
||||
if (state == Call.State.Connected) {
|
||||
if (call.dir == Call.Dir.Incoming) {
|
||||
answerCall()
|
||||
} else {
|
||||
scope.launch {
|
||||
Log.i("$TAG Setting call active")
|
||||
val result = callControl.setActive()
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to set call control active: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (state == Call.State.End) {
|
||||
callEnded()
|
||||
} else if (state == Call.State.Error) {
|
||||
callError(message)
|
||||
} else if (state == Call.State.Pausing) {
|
||||
scope.launch {
|
||||
Log.i("$TAG Pausing call")
|
||||
val result = callControl.setInactive()
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to set call control inactive: $result")
|
||||
}
|
||||
}
|
||||
} else if (state == Call.State.Resuming) {
|
||||
scope.launch {
|
||||
Log.i("$TAG Resuming call")
|
||||
val result = callControl.setActive()
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to set call control active: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// NEVER CALL ANY METHOD FROM callControl OBJECT IN HERE!
|
||||
Log.i("$TAG Created callback for call")
|
||||
coreContext.postOnCoreThread {
|
||||
call.addListener(callListener)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCallControlCallbackSet() {
|
||||
Log.i(
|
||||
"$TAG Callback have been set for call, Telecom call ID is [${callControl.getCallId()}]"
|
||||
)
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
val state = call.state
|
||||
Log.i("$TAG Call state currently is [$state]")
|
||||
when (state) {
|
||||
Call.State.Connected, Call.State.StreamsRunning -> answerCall()
|
||||
Call.State.End -> callEnded()
|
||||
Call.State.Error -> callError("")
|
||||
Call.State.Released -> callEnded()
|
||||
else -> {} // doing nothing
|
||||
}
|
||||
}
|
||||
|
||||
callControl.availableEndpoints.onEach { list ->
|
||||
Log.i("$TAG New available audio endpoints list but ignoring it")
|
||||
}.launchIn(scope)
|
||||
|
||||
callControl.currentCallEndpoint.onEach { endpoint ->
|
||||
Log.i("$TAG Android requests us to use [${endpoint.name}] audio endpoint with type [${endpointTypeToString(endpoint.type)}], ignoring it")
|
||||
}.launchIn(scope)
|
||||
|
||||
callControl.isMuted.onEach { muted ->
|
||||
coreContext.postOnCoreThread {
|
||||
val callState = call.state
|
||||
Log.i(
|
||||
"$TAG We're asked to [${if (muted) "mute" else "unmute"}] the call in state [$callState]"
|
||||
)
|
||||
// Only follow un-mute requests for not outgoing calls (such as joining a conference muted)
|
||||
// and if connected to Android Auto that has a way to let user mute/unmute from the car directly
|
||||
// or if we muted the call previously following Telecom Manager request.
|
||||
if (muted || mutedByTelecomManager || (!LinphoneUtils.isCallOutgoing(callState, false) && coreContext.isConnectedToAndroidAuto)) {
|
||||
mutedByTelecomManager = muted
|
||||
call.microphoneMuted = muted
|
||||
coreContext.refreshMicrophoneMuteStateEvent.postValue(Event(true))
|
||||
} else {
|
||||
if (coreContext.isConnectedToAndroidAuto) {
|
||||
Log.w(
|
||||
"$TAG Not following unmute request because call is in state [$callState]"
|
||||
)
|
||||
} else {
|
||||
Log.w(
|
||||
"$TAG Not following unmute request because user isn't connected to Android Auto and call is in state [$callState]"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
private fun answerCall() {
|
||||
val isVideo = LinphoneUtils.isVideoEnabled(call)
|
||||
val type = if (isVideo) {
|
||||
CallAttributesCompat.CALL_TYPE_VIDEO_CALL
|
||||
} else {
|
||||
CallAttributesCompat.CALL_TYPE_AUDIO_CALL
|
||||
}
|
||||
scope.launch {
|
||||
Log.i("$TAG Answering [${if (isVideo) "video" else "audio"}] call")
|
||||
val result = callControl.answer(type)
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to answer call control: $result")
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideo && corePreferences.routeAudioToSpeakerWhenVideoIsEnabled) {
|
||||
Log.i("$TAG Answering video call, routing audio to speaker")
|
||||
AudioUtils.routeAudioToSpeaker(call)
|
||||
}
|
||||
}
|
||||
|
||||
private fun callEnded() {
|
||||
val reason = call.reason
|
||||
val direction = call.dir
|
||||
scope.launch {
|
||||
val disconnectCause = when (reason) {
|
||||
Reason.NotAnswered -> DisconnectCause.REMOTE
|
||||
Reason.Declined -> DisconnectCause.REJECTED
|
||||
Reason.Busy -> {
|
||||
if (direction == Call.Dir.Incoming) {
|
||||
DisconnectCause.MISSED
|
||||
} else {
|
||||
DisconnectCause.BUSY
|
||||
}
|
||||
}
|
||||
else -> DisconnectCause.LOCAL
|
||||
}
|
||||
Log.i("$TAG Disconnecting [${if (direction == Call.Dir.Incoming)"incoming" else "outgoing"}] call with cause [${disconnectCauseToString(disconnectCause)}] because it has ended with reason [$reason]")
|
||||
try {
|
||||
val result = callControl.disconnect(DisconnectCause(disconnectCause))
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to disconnect call control: $result")
|
||||
}
|
||||
} catch (ise: IllegalArgumentException) {
|
||||
Log.e("$TAG Couldn't disconnect call control with cause [${disconnectCauseToString(disconnectCause)}]: $ise")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun callError(message: String) {
|
||||
val reason = call.reason
|
||||
scope.launch {
|
||||
// For some reason DisconnectCause.ERROR or DisconnectCause.BUSY triggers an IllegalArgumentException with following message
|
||||
// Valid DisconnectCause codes are limited to [DisconnectCause.LOCAL, DisconnectCause.REMOTE, DisconnectCause.MISSED, or DisconnectCause.REJECTED]
|
||||
val disconnectCause = DisconnectCause.REJECTED
|
||||
Log.w("$TAG Disconnecting call with cause [${disconnectCauseToString(disconnectCause)}] due to error [$message] and reason [$reason]")
|
||||
try {
|
||||
val result = callControl.disconnect(DisconnectCause(disconnectCause))
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to disconnect call control: $result")
|
||||
}
|
||||
} catch (ise: IllegalArgumentException) {
|
||||
Log.e("$TAG Couldn't disconnect call control with cause [${disconnectCauseToString(disconnectCause)}]: $ise")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnectCauseToString(cause: Int): String {
|
||||
return when (cause) {
|
||||
DisconnectCause.UNKNOWN -> "UNKNOWN"
|
||||
DisconnectCause.ERROR -> "ERROR"
|
||||
DisconnectCause.LOCAL -> "LOCAL"
|
||||
DisconnectCause.REMOTE -> "REMOTE"
|
||||
DisconnectCause.CANCELED -> "CANCELED"
|
||||
DisconnectCause.MISSED -> "MISSED"
|
||||
DisconnectCause.REJECTED -> "REJECTED"
|
||||
DisconnectCause.BUSY -> "BUSY"
|
||||
DisconnectCause.RESTRICTED -> "RESTRICTED"
|
||||
DisconnectCause.OTHER -> "OTHER"
|
||||
DisconnectCause.CONNECTION_MANAGER_NOT_SUPPORTED -> "CONNECTION_MANAGER_NOT_SUPPORTED"
|
||||
DisconnectCause.ANSWERED_ELSEWHERE -> "ANSWERED_ELSEWHERE"
|
||||
DisconnectCause.CALL_PULLED -> "CALL_PULLED"
|
||||
else -> "UNEXPECTED: $cause"
|
||||
}
|
||||
}
|
||||
|
||||
private fun endpointTypeToString(type: Int): String {
|
||||
return when (type) {
|
||||
CallEndpointCompat.TYPE_UNKNOWN -> "UNKNOWN"
|
||||
CallEndpointCompat.TYPE_EARPIECE -> "EARPIECE"
|
||||
CallEndpointCompat.TYPE_BLUETOOTH -> "BLUETOOTH"
|
||||
CallEndpointCompat.TYPE_WIRED_HEADSET -> "WIRED HEADSET"
|
||||
CallEndpointCompat.TYPE_SPEAKER -> "SPEAKER"
|
||||
CallEndpointCompat.TYPE_STREAMING -> "STREAMING"
|
||||
else -> "UNEXPECTED: $type"
|
||||
}
|
||||
}
|
||||
}
|
||||
211
app/src/main/java/org/linphone/telecom/TelecomManager.kt
Normal file
211
app/src/main/java/org/linphone/telecom/TelecomManager.kt
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.telecom
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.telecom.CallAttributesCompat
|
||||
import androidx.core.telecom.CallException
|
||||
import androidx.core.telecom.CallsManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.Core
|
||||
import org.linphone.core.CoreListenerStub
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
import androidx.core.net.toUri
|
||||
|
||||
class TelecomManager
|
||||
@WorkerThread
|
||||
constructor(context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "[Telecom Manager]"
|
||||
}
|
||||
|
||||
private val callsManager = CallsManager(context)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val map = HashMap<String, TelecomCallControlCallback>()
|
||||
|
||||
private val coreListener = object : CoreListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onCallStateChanged(
|
||||
core: Core,
|
||||
call: Call,
|
||||
state: Call.State?,
|
||||
message: String
|
||||
) {
|
||||
if (state == Call.State.IncomingReceived || state == Call.State.OutgoingProgress) {
|
||||
onCallCreated(call)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onLastCallEnded(core: Core) {
|
||||
currentlyFollowedCalls = 0
|
||||
}
|
||||
}
|
||||
|
||||
private val hasTelecomFeature = context.packageManager.hasSystemFeature("android.software.telecom")
|
||||
|
||||
private var currentlyFollowedCalls: Int = 0
|
||||
|
||||
init {
|
||||
Log.i(
|
||||
"$TAG android.software.telecom feature is [${if (hasTelecomFeature) "available" else "not available"}]"
|
||||
)
|
||||
try {
|
||||
callsManager.registerAppWithTelecom(CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING)
|
||||
Log.i("$TAG App has been registered with Telecom")
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Can't init TelecomManager: $e")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun onCallCreated(call: Call) {
|
||||
Log.i("$TAG Call to [${call.remoteAddress.asStringUriOnly()}] created in state [${call.state}]")
|
||||
|
||||
val address = call.callLog.remoteAddress
|
||||
val uri = address.asStringUriOnly().toUri()
|
||||
|
||||
val direction = if (call.dir == Call.Dir.Outgoing) {
|
||||
CallAttributesCompat.DIRECTION_OUTGOING
|
||||
} else {
|
||||
CallAttributesCompat.DIRECTION_INCOMING
|
||||
}
|
||||
|
||||
val capabilities = CallAttributesCompat.SUPPORTS_SET_INACTIVE or CallAttributesCompat.SUPPORTS_TRANSFER
|
||||
|
||||
val conferenceInfo = LinphoneUtils.getConferenceInfoIfAny(call)
|
||||
val displayName = if (call.conference != null || conferenceInfo != null) {
|
||||
conferenceInfo?.subject ?: call.conference?.subject ?: LinphoneUtils.getDisplayName(address)
|
||||
} else {
|
||||
val friend = coreContext.contactsManager.findContactByAddress(address)
|
||||
friend?.name ?: LinphoneUtils.getDisplayName(address)
|
||||
}
|
||||
|
||||
// Always set type to video (if enabled in Core) as it indicates that video is supported, not that it's being used at the time
|
||||
// https://developer.android.com/reference/kotlin/androidx/core/telecom/CallAttributesCompat#CALL_TYPE_VIDEO_CALL()
|
||||
val type = if (!call.core.isVideoEnabled) {
|
||||
CallAttributesCompat.CALL_TYPE_AUDIO_CALL
|
||||
} else {
|
||||
CallAttributesCompat.CALL_TYPE_VIDEO_CALL
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val callAttributes = CallAttributesCompat(
|
||||
displayName,
|
||||
uri,
|
||||
direction,
|
||||
type,
|
||||
capabilities
|
||||
)
|
||||
Log.i("$TAG Adding call to Telecom's CallsManager with attributes [$callAttributes]")
|
||||
|
||||
callsManager.addCall(
|
||||
callAttributes,
|
||||
{ callType -> // onAnswer
|
||||
Log.i("$TAG We're asked to answer the call with type [$callType]")
|
||||
coreContext.postOnCoreThread {
|
||||
if (LinphoneUtils.isCallIncoming(call.state)) {
|
||||
Log.i("$TAG Answering call")
|
||||
coreContext.answerCall(call)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ disconnectCause -> // onDisconnect
|
||||
Log.i(
|
||||
"$TAG We're asked to terminate the call with reason [$disconnectCause]"
|
||||
)
|
||||
coreContext.postOnCoreThread {
|
||||
coreContext.terminateCall(call)
|
||||
}
|
||||
currentlyFollowedCalls -= 1
|
||||
},
|
||||
{ // onSetActive
|
||||
Log.i("$TAG We're asked to resume the call")
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG Resuming call")
|
||||
call.resume()
|
||||
}
|
||||
},
|
||||
{ // onSetInactive
|
||||
Log.i("$TAG We're asked to pause the call")
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG Pausing call")
|
||||
call.pause()
|
||||
}
|
||||
}
|
||||
) {
|
||||
val callbacks = TelecomCallControlCallback(call, this, scope)
|
||||
|
||||
// We must first call setCallback on callControlScope before using it
|
||||
callbacks.onCallControlCallbackSet()
|
||||
currentlyFollowedCalls += 1
|
||||
Log.i("$TAG Call added to Telecom's CallsManager")
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
val callId = call.callLog.callId.orEmpty()
|
||||
if (callId.isNotEmpty()) {
|
||||
Log.i("$TAG Storing our callbacks for call ID [$callId]")
|
||||
map[callId] = callbacks
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ce: CallException) {
|
||||
Log.e("$TAG Failed to add call to Telecom's CallsManager: $ce")
|
||||
} catch (se: SecurityException) {
|
||||
Log.e("$TAG Security exception trying to add call to Telecom's CallsManager: $se")
|
||||
} catch (ise: IllegalArgumentException) {
|
||||
Log.e("$TAG Illegal argument exception trying to add call to Telecom's CallsManager: $ise")
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Exception trying to add call to Telecom's CallsManager: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun onCoreStarted(core: Core) {
|
||||
Log.i("$TAG Core has been started")
|
||||
if (hasTelecomFeature) {
|
||||
core.addListener(coreListener)
|
||||
} else {
|
||||
Log.w(
|
||||
"$TAG android.software.telecom feature is not available, enable audio focus requests in Linphone SDK"
|
||||
)
|
||||
coreContext.core.config.setBool("audio", "android_disable_audio_focus_requests", false)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun onCoreStopped(core: Core) {
|
||||
Log.i("$TAG Core is being stopped")
|
||||
if (hasTelecomFeature) {
|
||||
core.removeListener(coreListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
134
app/src/main/java/org/linphone/telecom/auto/AndroidAutoScreen.kt
Normal file
134
app/src/main/java/org/linphone/telecom/auto/AndroidAutoScreen.kt
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.telecom.auto
|
||||
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.GridItem
|
||||
import androidx.car.app.model.GridTemplate
|
||||
import androidx.car.app.model.Header
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.contacts.AvatarGenerator
|
||||
import org.linphone.contacts.getAvatarBitmap
|
||||
import org.linphone.core.MagicSearch
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
class AndroidAutoScreen(context: CarContext) : Screen(context) {
|
||||
companion object {
|
||||
private const val TAG = "[Android Auto Screen]"
|
||||
}
|
||||
|
||||
private val favoritesList = arrayListOf<GridItem>()
|
||||
|
||||
private var loading = true
|
||||
|
||||
init {
|
||||
Log.i(
|
||||
"$TAG Creating favorites contacts list template for host with API level [${carContext.carAppApiLevel}]"
|
||||
)
|
||||
coreContext.postOnCoreThread { core ->
|
||||
val magicSearch = core.createMagicSearch()
|
||||
val results = magicSearch.getContactsList(
|
||||
"",
|
||||
LinphoneUtils.getDefaultAccount()?.params?.domain.orEmpty(),
|
||||
MagicSearch.Source.FavoriteFriends.toInt(),
|
||||
MagicSearch.Aggregation.Friend
|
||||
)
|
||||
val favorites = arrayListOf<GridItem>()
|
||||
for (result in results) {
|
||||
val builder = GridItem.Builder()
|
||||
val friend = result.friend ?: continue
|
||||
|
||||
builder.setTitle(friend.name)
|
||||
Log.i("$TAG Creating car icon for friend [${friend.name}]")
|
||||
try {
|
||||
val bitmap = friend.getAvatarBitmap(true) ?: AvatarGenerator(
|
||||
coreContext.context
|
||||
).setInitials(
|
||||
AppUtils.getInitials(friend.name.orEmpty())
|
||||
).buildBitmap(useTransparentBackground = false)
|
||||
builder.setImage(
|
||||
CarIcon.Builder(IconCompat.createWithBitmap(bitmap))
|
||||
.build(),
|
||||
GridItem.IMAGE_TYPE_LARGE
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Exception trying to create CarIcon: $e")
|
||||
}
|
||||
|
||||
builder.setOnClickListener {
|
||||
val address = friend.address ?: friend.addresses.firstOrNull()
|
||||
if (address != null) {
|
||||
Log.i("$TAG Starting audio call to [${address.asStringUriOnly()}]")
|
||||
coreContext.startAudioCall(address)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val item = builder.build()
|
||||
favorites.add(item)
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Failed to build grid item: $e")
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
Log.i("$TAG Processed [${favorites.size}] favorites")
|
||||
|
||||
coreContext.postOnMainThread {
|
||||
favoritesList.addAll(favorites)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
Log.i("$TAG onGetTemplate called, favorites are [${if (loading) "loading" else "loaded"}]")
|
||||
|
||||
val listBuilder = ItemList.Builder()
|
||||
listBuilder.setNoItemsMessage(
|
||||
carContext.getString(R.string.car_favorites_contacts_list_empty)
|
||||
)
|
||||
for (favorite in favoritesList) {
|
||||
listBuilder.addItem(favorite)
|
||||
}
|
||||
val list = listBuilder.build()
|
||||
|
||||
val header = Header.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_favorites_contacts_title))
|
||||
.setStartHeaderAction(Action.APP_ICON)
|
||||
.build()
|
||||
|
||||
val gridBuilder = GridTemplate.Builder()
|
||||
gridBuilder.setHeader(header)
|
||||
gridBuilder.setLoading(loading)
|
||||
if (!loading) {
|
||||
Log.i("$TAG Added [${favoritesList.size}] favorites items to grid")
|
||||
gridBuilder.setSingleList(list)
|
||||
}
|
||||
return gridBuilder.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.telecom.auto
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import androidx.car.app.CarAppService
|
||||
import androidx.car.app.Session
|
||||
import androidx.car.app.validation.HostValidator
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
|
||||
class AndroidAutoService : CarAppService() {
|
||||
companion object {
|
||||
private const val TAG = "[Android Auto Service]"
|
||||
}
|
||||
|
||||
override fun createHostValidator(): HostValidator {
|
||||
val host = hostInfo
|
||||
Log.i("$TAG Host is [${host?.packageName}] with UID [${host?.uid}]")
|
||||
|
||||
val validator = HostValidator.Builder(applicationContext)
|
||||
.addAllowedHosts(R.array.hosts_allowlist_sample_copy) // androidx.car.app.R.array.hosts_allowlist_sample
|
||||
.build()
|
||||
if (host != null) {
|
||||
val allowed = validator.isValidHost(host)
|
||||
Log.i("$TAG Host is [${if (allowed) "allowed" else "not allowed"}] in our validator")
|
||||
} else {
|
||||
Log.w("$TAG Host is null!")
|
||||
}
|
||||
|
||||
return if ((applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
|
||||
Log.w("$TAG App is in debug mode, allowing all hosts")
|
||||
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
|
||||
} else {
|
||||
validator
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateSession(): Session {
|
||||
Log.i("$TAG Creating Session object")
|
||||
return AndroidAutoSession()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.telecom.auto
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.Session
|
||||
import org.linphone.core.tools.Log
|
||||
|
||||
class AndroidAutoSession : Session() {
|
||||
companion object {
|
||||
private const val TAG = "[Android Auto Session]"
|
||||
}
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
Log.i("$TAG Creating Screen object for host with API level [${carContext.carAppApiLevel}]")
|
||||
return AndroidAutoScreen(carContext)
|
||||
}
|
||||
}
|
||||
271
app/src/main/java/org/linphone/ui/GenericActivity.kt
Normal file
271
app/src/main/java/org/linphone/ui/GenericActivity.kt
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.children
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.compatibility.Compatibility
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.ToastUtils
|
||||
import org.linphone.utils.slideInToastFromTop
|
||||
import org.linphone.utils.slideInToastFromTopForDuration
|
||||
|
||||
@MainThread
|
||||
open class GenericActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val TAG = "[Generic Activity]"
|
||||
}
|
||||
|
||||
private lateinit var toastsArea: ViewGroup
|
||||
|
||||
private var mainColor: String = "orange"
|
||||
|
||||
override fun getTheme(): Resources.Theme {
|
||||
mainColor = corePreferences.themeMainColor
|
||||
val theme = super.theme
|
||||
when (mainColor) {
|
||||
"yellow" -> theme.applyStyle(R.style.Theme_LinphoneYellow, true)
|
||||
"green" -> theme.applyStyle(R.style.Theme_LinphoneGreen, true)
|
||||
"blue" -> theme.applyStyle(R.style.Theme_LinphoneBlue, true)
|
||||
"red" -> theme.applyStyle(R.style.Theme_LinphoneRed, true)
|
||||
"pink" -> theme.applyStyle(R.style.Theme_LinphonePink, true)
|
||||
"purple" -> theme.applyStyle(R.style.Theme_LinphonePurple, true)
|
||||
else -> theme.applyStyle(R.style.Theme_Linphone, true)
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableWindowSecureMode(corePreferences.enableSecureMode)
|
||||
|
||||
val nightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
val darkModeEnabled = corePreferences.darkMode
|
||||
Log.i(
|
||||
"$TAG Theme selected in config file is [${if (darkModeEnabled == -1) "auto" else if (darkModeEnabled == 0) "light" else "dark"}]"
|
||||
)
|
||||
when (nightMode) {
|
||||
Configuration.UI_MODE_NIGHT_NO, Configuration.UI_MODE_NIGHT_UNDEFINED -> {
|
||||
if (darkModeEnabled == 1) {
|
||||
Compatibility.forceDarkMode(this)
|
||||
}
|
||||
}
|
||||
Configuration.UI_MODE_NIGHT_YES -> {
|
||||
if (darkModeEnabled == 0) {
|
||||
Compatibility.forceLightMode(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
protected fun checkMainColorTheme() {
|
||||
if (mainColor != corePreferences.themeMainColor) {
|
||||
Log.i("$TAG Main color setting has changed, re-creating activity")
|
||||
recreate()
|
||||
}
|
||||
}
|
||||
|
||||
fun setUpToastsArea(viewGroup: ViewGroup) {
|
||||
toastsArea = viewGroup
|
||||
}
|
||||
|
||||
fun showGreenToast(
|
||||
message: String,
|
||||
@DrawableRes icon: Int,
|
||||
duration: Long = 4000,
|
||||
doNotTint: Boolean = false
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val greenToast = ToastUtils.getGreenToast(
|
||||
this@GenericActivity,
|
||||
toastsArea,
|
||||
message,
|
||||
icon,
|
||||
doNotTint
|
||||
)
|
||||
toastsArea.addView(greenToast.root)
|
||||
|
||||
greenToast.root.slideInToastFromTopForDuration(
|
||||
toastsArea,
|
||||
lifecycleScope,
|
||||
duration
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showBlueToast(
|
||||
message: String,
|
||||
@DrawableRes icon: Int,
|
||||
duration: Long = 4000,
|
||||
doNotTint: Boolean = false
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val blueToast = ToastUtils.getBlueToast(
|
||||
this@GenericActivity,
|
||||
toastsArea,
|
||||
message,
|
||||
icon,
|
||||
doNotTint
|
||||
)
|
||||
toastsArea.addView(blueToast.root)
|
||||
|
||||
blueToast.root.slideInToastFromTopForDuration(
|
||||
toastsArea,
|
||||
lifecycleScope,
|
||||
duration
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showRedToast(
|
||||
message: String,
|
||||
@DrawableRes icon: Int,
|
||||
duration: Long = 4000,
|
||||
doNotTint: Boolean = false
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val redToast = ToastUtils.getRedToast(
|
||||
this@GenericActivity,
|
||||
toastsArea,
|
||||
message,
|
||||
icon,
|
||||
doNotTint
|
||||
)
|
||||
toastsArea.addView(redToast.root)
|
||||
|
||||
redToast.root.slideInToastFromTopForDuration(
|
||||
toastsArea,
|
||||
lifecycleScope,
|
||||
duration
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showPersistentRedToast(
|
||||
message: String,
|
||||
@DrawableRes icon: Int,
|
||||
tag: String,
|
||||
doNotTint: Boolean = false
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val redToast =
|
||||
ToastUtils.getRedToast(
|
||||
this@GenericActivity,
|
||||
toastsArea,
|
||||
message,
|
||||
icon,
|
||||
doNotTint
|
||||
)
|
||||
redToast.root.tag = tag
|
||||
toastsArea.addView(redToast.root)
|
||||
|
||||
redToast.root.slideInToastFromTop(
|
||||
toastsArea,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removePersistentRedToast(tag: String) {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
for (child in toastsArea.children) {
|
||||
if (child.tag == tag) {
|
||||
toastsArea.removeView(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToAndroidPermissionSettings() {
|
||||
Log.i("$TAG Going into Android settings for our app")
|
||||
try {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts(
|
||||
"package",
|
||||
packageName, null
|
||||
)
|
||||
)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(intent)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e("$TAG Failed to go to android settings: $anfe")
|
||||
}
|
||||
}
|
||||
|
||||
protected fun enableWindowSecureMode(enable: Boolean) {
|
||||
val flags: Int = window.attributes.flags
|
||||
if ((enable && flags and WindowManager.LayoutParams.FLAG_SECURE != 0) ||
|
||||
(!enable && flags and WindowManager.LayoutParams.FLAG_SECURE == 0)
|
||||
) {
|
||||
Log.d(
|
||||
"$TAG Secure flag is already ${if (enable) "enabled" else "disabled"}, skipping..."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
Log.i("$TAG Secure flag added to window")
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
Log.w("$TAG Secure flag cleared from window")
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
|
||||
if (window.decorView.isAttachedToWindow) {
|
||||
Log.d("$TAG Redrawing window decorView to apply flag")
|
||||
try {
|
||||
windowManager.updateViewLayout(window.decorView, window.attributes)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e("$TAG Failed to update window's decorView layout: $ise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
app/src/main/java/org/linphone/ui/GenericFragment.kt
Normal file
60
app/src/main/java/org/linphone/ui/GenericFragment.kt
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.fragment.app.Fragment
|
||||
|
||||
@UiThread
|
||||
abstract class GenericFragment : Fragment() {
|
||||
protected fun observeToastEvents(viewModel: GenericViewModel) {
|
||||
viewModel.showRedToastEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val message = getString(pair.first)
|
||||
val icon = pair.second
|
||||
(requireActivity() as GenericActivity).showRedToast(message, icon)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.showFormattedRedToastEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val message = pair.first
|
||||
val icon = pair.second
|
||||
(requireActivity() as GenericActivity).showRedToast(message, icon)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.showGreenToastEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val message = getString(pair.first)
|
||||
val icon = pair.second
|
||||
(requireActivity() as GenericActivity).showGreenToast(message, icon)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.showFormattedGreenToastEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val message = pair.first
|
||||
val icon = pair.second
|
||||
(requireActivity() as GenericActivity).showGreenToast(message, icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/src/main/java/org/linphone/ui/GenericViewModel.kt
Normal file
62
app/src/main/java/org/linphone/ui/GenericViewModel.kt
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.linphone.utils.Event
|
||||
|
||||
open class GenericViewModel : ViewModel() {
|
||||
// Message res id, icon
|
||||
val showGreenToastEvent: MutableLiveData<Event<Pair<Int, Int>>> by lazy {
|
||||
MutableLiveData<Event<Pair<Int, Int>>>()
|
||||
}
|
||||
|
||||
val showFormattedGreenToastEvent: MutableLiveData<Event<Pair<String, Int>>> by lazy {
|
||||
MutableLiveData<Event<Pair<String, Int>>>()
|
||||
}
|
||||
|
||||
// Message res id, icon
|
||||
val showRedToastEvent: MutableLiveData<Event<Pair<Int, Int>>> by lazy {
|
||||
MutableLiveData<Event<Pair<Int, Int>>>()
|
||||
}
|
||||
|
||||
val showFormattedRedToastEvent: MutableLiveData<Event<Pair<String, Int>>> by lazy {
|
||||
MutableLiveData<Event<Pair<String, Int>>>()
|
||||
}
|
||||
|
||||
fun showGreenToast(@StringRes message: Int, @DrawableRes icon: Int) {
|
||||
showGreenToastEvent.postValue(Event(Pair(message, icon)))
|
||||
}
|
||||
|
||||
fun showFormattedGreenToast(message: String, @DrawableRes icon: Int) {
|
||||
showFormattedGreenToastEvent.postValue(Event(Pair(message, icon)))
|
||||
}
|
||||
|
||||
fun showRedToast(@StringRes message: Int, @DrawableRes icon: Int) {
|
||||
showRedToastEvent.postValue(Event(Pair(message, icon)))
|
||||
}
|
||||
|
||||
fun showFormattedRedToast(message: String, @DrawableRes icon: Int) {
|
||||
showFormattedRedToastEvent.postValue(Event(Pair(message, icon)))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.databinding.ActivityMainBinding
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var viewModel: MainViewModel
|
||||
|
||||
private val onNavDestinationChangedListener =
|
||||
NavController.OnDestinationChangedListener { _, destination, _ ->
|
||||
binding.mainNavView?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
while (!coreContext.isReady()) {
|
||||
Thread.sleep(20)
|
||||
}
|
||||
|
||||
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
|
||||
binding.lifecycleOwner = this
|
||||
|
||||
viewModel = ViewModelProvider(this)[MainViewModel::class.java]
|
||||
binding.viewModel = viewModel
|
||||
|
||||
viewModel.unreadMessagesCount.observe(this) { count ->
|
||||
if (count > 0) {
|
||||
getNavBar()?.getOrCreateBadge(R.id.conversationsFragment)?.apply {
|
||||
isVisible = true
|
||||
number = count
|
||||
}
|
||||
} else {
|
||||
getNavBar()?.removeBadge(R.id.conversationsFragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
|
||||
binding.mainNavHostFragment.findNavController()
|
||||
.addOnDestinationChangedListener(onNavDestinationChangedListener)
|
||||
|
||||
getNavBar()?.setupWithNavController(binding.mainNavHostFragment.findNavController())
|
||||
}
|
||||
|
||||
private fun getNavBar(): NavigationBarView? {
|
||||
return binding.mainNavView ?: binding.mainNavRail
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/org/linphone/ui/NotoSansFont.kt
Normal file
32
app/src/main/java/org/linphone/ui/NotoSansFont.kt
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui
|
||||
|
||||
import androidx.annotation.FontRes
|
||||
import org.linphone.R
|
||||
|
||||
enum class NotoSansFont(@FontRes val fontRes: Int) {
|
||||
// NotoSansLight(R.font.noto_sans_light), // 300
|
||||
NotoSansRegular(R.font.noto_sans_regular), // 400
|
||||
NotoSansMedium(R.font.noto_sans_medium), // 500
|
||||
// NotoSansSemiBold(R.font.noto_sans_semi_bold), // 600
|
||||
NotoSansBold(R.font.noto_sans_bold), // 700
|
||||
NotoSansExtraBold(R.font.noto_sans_extra_bold) // 800
|
||||
}
|
||||
123
app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt
Normal file
123
app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.navigation.findNavController
|
||||
import kotlin.math.max
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.compatibility.Compatibility
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.AssistantActivityBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.assistant.fragment.PermissionsFragmentDirections
|
||||
|
||||
@UiThread
|
||||
class AssistantActivity : GenericActivity() {
|
||||
companion object {
|
||||
private const val TAG = "[Assistant Activity]"
|
||||
|
||||
const val SKIP_LANDING_EXTRA = "SkipLandingIfAtLeastAnAccount"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantActivityBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = DataBindingUtil.setContentView(this, R.layout.assistant_activity)
|
||||
binding.lifecycleOwner = this
|
||||
setUpToastsArea(binding.toastsArea)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, windowInsets ->
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val keyboard = windowInsets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
v.updatePadding(
|
||||
insets.left,
|
||||
insets.top,
|
||||
insets.right,
|
||||
max(insets.bottom, keyboard.bottom)
|
||||
)
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
if (core.accountList.isEmpty()) {
|
||||
Log.i("$TAG No account configured, disabling back gesture")
|
||||
coreContext.postOnMainThread {
|
||||
// Disable back gesture / button
|
||||
onBackPressedDispatcher.addCallback { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(binding.root as? ViewGroup)?.doOnPreDraw {
|
||||
if (!areAllPermissionsGranted()) {
|
||||
Log.w("$TAG Not all required permissions are granted, showing Permissions fragment")
|
||||
val action = PermissionsFragmentDirections.actionGlobalPermissionsFragment()
|
||||
binding.assistantNavContainer.findNavController().navigate(action)
|
||||
} else if (intent.getBooleanExtra(SKIP_LANDING_EXTRA, false)) {
|
||||
Log.w(
|
||||
"$TAG We were asked to leave assistant if at least an account is already configured"
|
||||
)
|
||||
coreContext.postOnCoreThread { core ->
|
||||
if (core.accountList.isNotEmpty()) {
|
||||
coreContext.postOnMainThread {
|
||||
try {
|
||||
Log.w("$TAG At least one account was found, leaving assistant")
|
||||
finish()
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e("$TAG Can't finish activity: $ise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun areAllPermissionsGranted(): Boolean {
|
||||
for (permission in Compatibility.getAllRequiredPermissionsArray()) {
|
||||
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w("$TAG Permission [$permission] hasn't been granted yet!")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val granted = Compatibility.hasFullScreenIntentPermission(this)
|
||||
if (granted) {
|
||||
Log.i("$TAG All permissions have been granted!")
|
||||
}
|
||||
return granted
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.telephony.TelephonyManager
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.AssistantLandingFragmentBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.assistant.model.AcceptConditionsAndPolicyDialogModel
|
||||
import org.linphone.ui.assistant.viewmodel.AccountLoginViewModel
|
||||
import org.linphone.utils.DialogUtils
|
||||
import org.linphone.utils.PhoneNumberUtils
|
||||
import androidx.core.net.toUri
|
||||
|
||||
@UiThread
|
||||
class LandingFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Landing Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantLandingFragmentBinding
|
||||
|
||||
private val viewModel: AccountLoginViewModel by navGraphViewModels(
|
||||
R.id.assistant_nav_graph
|
||||
)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = AssistantLandingFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
binding.setBackClickListener {
|
||||
requireActivity().finish()
|
||||
}
|
||||
|
||||
binding.setHelpClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.landingFragment) {
|
||||
val action =
|
||||
LandingFragmentDirections.actionLandingFragmentToHelpFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setRegisterClickListener {
|
||||
if (viewModel.conditionsAndPrivacyPolicyAccepted) {
|
||||
goToRegisterFragment()
|
||||
} else {
|
||||
showAcceptConditionsAndPrivacyDialog(goToAccountCreate = true)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setQrCodeClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.landingFragment) {
|
||||
val action =
|
||||
LandingFragmentDirections.actionLandingFragmentToQrCodeScannerFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setThirdPartySipAccountLoginClickListener {
|
||||
if (viewModel.conditionsAndPrivacyPolicyAccepted) {
|
||||
goToLoginThirdPartySipAccountFragment(false)
|
||||
} else {
|
||||
showAcceptConditionsAndPrivacyDialog(goToThirdPartySipAccountLogin = true)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setForgottenPasswordClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.landingFragment) {
|
||||
val action =
|
||||
LandingFragmentDirections.actionLandingFragmentToRecoverAccountFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.showPassword.observe(viewLifecycleOwner) {
|
||||
lifecycleScope.launch {
|
||||
delay(50)
|
||||
binding.password.setSelection(binding.password.text?.length ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.accountLoggedInEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Account successfully logged-in, leaving assistant")
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.accountLoginErrorEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { message ->
|
||||
(requireActivity() as GenericActivity).showRedToast(
|
||||
message,
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.skipLandingToThirdPartySipAccountEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
goToLoginThirdPartySipAccountFragment(true)
|
||||
}
|
||||
}
|
||||
|
||||
val telephonyManager = requireContext().getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
|
||||
val countryIso = telephonyManager.networkCountryIso
|
||||
coreContext.postOnCoreThread {
|
||||
val dialPlan = PhoneNumberUtils.getDeviceDialPlan(countryIso)
|
||||
if (dialPlan != null) {
|
||||
viewModel.internationalPrefix.postValue(dialPlan.countryCallingCode)
|
||||
viewModel.internationalPrefixIsoCountryCode.postValue(dialPlan.isoCountryCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToRegisterFragment() {
|
||||
if (findNavController().currentDestination?.id == R.id.landingFragment) {
|
||||
val action = LandingFragmentDirections.actionLandingFragmentToRegisterFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToLoginThirdPartySipAccountFragment(skipWarning: Boolean) {
|
||||
if (findNavController().currentDestination?.id == R.id.landingFragment) {
|
||||
val action = if (skipWarning) {
|
||||
LandingFragmentDirections.actionLandingFragmentToThirdPartySipAccountLoginFragment()
|
||||
} else {
|
||||
LandingFragmentDirections.actionLandingFragmentToThirdPartySipAccountWarningFragment()
|
||||
}
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAcceptConditionsAndPrivacyDialog(
|
||||
goToAccountCreate: Boolean = false,
|
||||
goToThirdPartySipAccountLogin: Boolean = false
|
||||
) {
|
||||
val model = AcceptConditionsAndPolicyDialogModel()
|
||||
val dialog = DialogUtils.getAcceptConditionsAndPrivacyDialog(
|
||||
requireActivity(),
|
||||
model
|
||||
)
|
||||
|
||||
model.dismissEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
model.conditionsAcceptedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Conditions & Privacy policy have been accepted")
|
||||
coreContext.postOnCoreThread {
|
||||
corePreferences.conditionsAndPrivacyPolicyAccepted = true
|
||||
}
|
||||
dialog.dismiss()
|
||||
|
||||
if (goToAccountCreate) {
|
||||
goToRegisterFragment()
|
||||
} else if (goToThirdPartySipAccountLogin) {
|
||||
goToLoginThirdPartySipAccountFragment(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
model.privacyPolicyClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val url = getString(R.string.website_privacy_policy_url)
|
||||
openUrlInBrowser(url)
|
||||
}
|
||||
}
|
||||
|
||||
model.generalTermsClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val url = getString(R.string.website_terms_and_conditions_url)
|
||||
openUrlInBrowser(url)
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun openUrlInBrowser(url: String) {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.compatibility.Compatibility
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.AssistantPermissionsFragmentBinding
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.assistant.AssistantActivity
|
||||
import org.linphone.ui.assistant.viewmodel.PermissionsViewModel
|
||||
import kotlin.getValue
|
||||
|
||||
@UiThread
|
||||
class PermissionsFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Permissions Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantPermissionsFragmentBinding
|
||||
|
||||
private val viewModel: PermissionsViewModel by navGraphViewModels(
|
||||
R.id.assistant_nav_graph
|
||||
)
|
||||
|
||||
private var leaving = false
|
||||
|
||||
private val requestPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
var allGranted = true
|
||||
permissions.entries.forEach {
|
||||
val permissionName = it.key
|
||||
val isGranted = it.value
|
||||
if (isGranted) {
|
||||
Log.i("Permission [$permissionName] is now granted")
|
||||
} else {
|
||||
Log.i("Permission [$permissionName] has been denied")
|
||||
allGranted = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!allGranted) {
|
||||
Log.w(
|
||||
"$TAG Not all permissions were granted, leaving anyway, they will be asked again later..."
|
||||
)
|
||||
}
|
||||
leave()
|
||||
}
|
||||
|
||||
private val telecomManagerPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
Log.i("$TAG MANAGE_OWN_CALLS permission has been granted")
|
||||
} else {
|
||||
Log.w("$TAG MANAGE_OWN_CALLS permission has been denied, leaving this fragment")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = AssistantPermissionsFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
|
||||
binding.setBackClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
binding.setSkipClickListener {
|
||||
Log.i("$TAG User clicked skip...")
|
||||
leave()
|
||||
}
|
||||
|
||||
binding.setGrantAllClickListener {
|
||||
Log.i("$TAG Requesting all permissions")
|
||||
requestPermissionLauncher.launch(
|
||||
Compatibility.getAllRequiredPermissionsArray()
|
||||
)
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
Manifest.permission.MANAGE_OWN_CALLS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.i("$TAG Request MANAGE_OWN_CALLS permission")
|
||||
telecomManagerPermissionLauncher.launch(Manifest.permission.MANAGE_OWN_CALLS)
|
||||
}
|
||||
|
||||
if (!Compatibility.hasFullScreenIntentPermission(requireContext())) {
|
||||
Log.w(
|
||||
"$TAG Android 14 or newer detected & full screen intent permission hasn't been granted!"
|
||||
)
|
||||
Compatibility.requestFullScreenIntentPermission(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (!leaving && areAllPermissionsGranted()) {
|
||||
Log.i("$TAG All permissions have been granted, skipping")
|
||||
leave()
|
||||
}
|
||||
}
|
||||
|
||||
private fun leave() {
|
||||
if (leaving) return
|
||||
leaving = true
|
||||
|
||||
if (requireActivity().intent.getBooleanExtra(AssistantActivity.SKIP_LANDING_EXTRA, false)) {
|
||||
Log.w(
|
||||
"$TAG We were asked to leave assistant if at least an account is already configured"
|
||||
)
|
||||
coreContext.postOnCoreThread { core ->
|
||||
if (core.accountList.isNotEmpty()) {
|
||||
coreContext.postOnMainThread {
|
||||
Log.w("$TAG At least one account was found, leaving assistant")
|
||||
try {
|
||||
requireActivity().finish()
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e("$TAG Failed to finish activity: $ise")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
coreContext.postOnMainThread {
|
||||
Log.w("$TAG No account was found, going to landing fragment")
|
||||
try {
|
||||
goToLoginFragment()
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e("$TAG Failed to navigate to login fragment: $ise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
goToLoginFragment()
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToLoginFragment() {
|
||||
if (findNavController().currentDestination?.id == R.id.permissionsFragment) {
|
||||
val action =
|
||||
PermissionsFragmentDirections.actionPermissionsFragmentToLandingFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun areAllPermissionsGranted(): Boolean {
|
||||
for (permission in Compatibility.getAllRequiredPermissionsArray()) {
|
||||
val granted = ContextCompat.checkSelfPermission(requireContext(), permission) == PackageManager.PERMISSION_GRANTED
|
||||
viewModel.setPermissionGranted(permission, granted)
|
||||
if (!granted) {
|
||||
Log.w("$TAG Permission [$permission] hasn't been granted yet!")
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
return Compatibility.hasFullScreenIntentPermission(requireContext())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.AssistantQrCodeScannerFragmentBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.assistant.viewmodel.QrCodeViewModel
|
||||
import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections
|
||||
|
||||
@UiThread
|
||||
class QrCodeScannerFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Qr Code Scanner Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantQrCodeScannerFragmentBinding
|
||||
|
||||
private val viewModel: QrCodeViewModel by navGraphViewModels(
|
||||
R.id.assistant_nav_graph
|
||||
)
|
||||
|
||||
private val requestPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
Log.i("$TAG CAMERA permission has been granted")
|
||||
enableQrCodeVideoScanner()
|
||||
} else {
|
||||
Log.e("$TAG CAMERA permission has been denied, leaving this fragment")
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = AssistantQrCodeScannerFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
viewModel.remoteProvisioningSuccessfulEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { atLeastOneAccountFound ->
|
||||
if (atLeastOneAccountFound) {
|
||||
requireActivity().finish()
|
||||
} else {
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.onErrorEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
// Core has restarted but something went wrong, restart video capture
|
||||
enableQrCodeVideoScanner()
|
||||
}
|
||||
}
|
||||
|
||||
coreContext.bearerAuthenticationRequestedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val serverUrl = pair.first
|
||||
val username = pair.second
|
||||
|
||||
Log.i(
|
||||
"$TAG Navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]"
|
||||
)
|
||||
if (findNavController().currentDestination?.id == R.id.qrCodeScannerFragment) {
|
||||
val action = SingleSignOnFragmentDirections.actionGlobalSingleSignOnFragment(
|
||||
serverUrl,
|
||||
username
|
||||
)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCameraPermissionGranted()) {
|
||||
if (ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), Manifest.permission.CAMERA)) {
|
||||
Log.w("$TAG CAMERA permission wasn't granted yet, asking for it now")
|
||||
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
} else {
|
||||
Log.i("$TAG Permission request for CAMERA will be automatically denied, go to android app settings instead")
|
||||
(requireActivity() as GenericActivity).goToAndroidPermissionSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (isCameraPermissionGranted()) {
|
||||
Log.i(
|
||||
"$TAG Record video permission is granted, starting video preview with back cam if possible"
|
||||
)
|
||||
viewModel.setBackCamera()
|
||||
enableQrCodeVideoScanner()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.nativePreviewWindowId = null
|
||||
core.isVideoPreviewEnabled = false
|
||||
core.isQrcodeVideoPreviewEnabled = false
|
||||
|
||||
coreContext.setFrontCamera()
|
||||
}
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun goBack() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun isCameraPermissionGranted(): Boolean {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
Log.i("$TAG CAMERA permission is ${if (granted) "granted" else "denied"}")
|
||||
return granted
|
||||
}
|
||||
|
||||
private fun enableQrCodeVideoScanner() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.nativePreviewWindowId = binding.qrCodePreview
|
||||
core.isQrcodeVideoPreviewEnabled = true
|
||||
core.isVideoPreviewEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.databinding.AssistantRecoverAccountFragmentBinding
|
||||
import org.linphone.ui.assistant.viewmodel.AccountCreationViewModel
|
||||
import kotlin.getValue
|
||||
|
||||
@UiThread
|
||||
class RecoverAccountFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Recover Account Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantRecoverAccountFragmentBinding
|
||||
|
||||
private val viewModel: AccountCreationViewModel by navGraphViewModels(
|
||||
R.id.assistant_nav_graph
|
||||
)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = AssistantRecoverAccountFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
viewModel.accountRecoveryTokenReceivedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { token ->
|
||||
Log.i("$TAG Account recovery token received [$token], opening browser")
|
||||
recoverPhoneNumberAccount(token)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
binding.setRecoverEmailAccountClickListener {
|
||||
recoverEmailAccount()
|
||||
}
|
||||
|
||||
binding.setRecoverPhoneNumberAccountClickListener {
|
||||
viewModel.requestAccountRecoveryToken()
|
||||
}
|
||||
}
|
||||
|
||||
private fun goBack() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun recoverEmailAccount() {
|
||||
val rootUrl = getString(R.string.web_platform_forgotten_password_url)
|
||||
val url = "$rootUrl/recovery/email"
|
||||
try {
|
||||
Log.i("$TAG Trying to open [$url] URL")
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recoverPhoneNumberAccount(recoveryToken: String) {
|
||||
val rootUrl = getString(R.string.web_platform_forgotten_password_url)
|
||||
val url = "$rootUrl/recovery/phone/$recoveryToken"
|
||||
try {
|
||||
Log.i("$TAG Trying to open [$url] URL")
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.AssistantRegisterConfirmSmsCodeFragmentBinding
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.assistant.viewmodel.AccountCreationViewModel
|
||||
|
||||
@UiThread
|
||||
class RegisterCodeConfirmationFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Register Code Confirmation Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantRegisterConfirmSmsCodeFragmentBinding
|
||||
|
||||
private val viewModel: AccountCreationViewModel by navGraphViewModels(
|
||||
R.id.assistant_nav_graph
|
||||
)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = AssistantRegisterConfirmSmsCodeFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
viewModel.accountCreatedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val identity = viewModel.username.value.orEmpty()
|
||||
Log.i("$TAG Account [$identity] has been created, leaving assistant")
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
|
||||
// This won't work starting Android 10 as clipboard access is denied unless app has focus,
|
||||
// which won't be the case when the SMS arrives unless it is added into clipboard from a notification
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.addPrimaryClipChangedListener {
|
||||
val data = clipboard.primaryClip
|
||||
if (data != null && data.itemCount > 0) {
|
||||
val clip = data.getItemAt(0).text?.toString() ?: ""
|
||||
if (clip.length == 4) {
|
||||
Log.i(
|
||||
"$TAG Found 4 digits [$clip] as primary clip in clipboard, using it and clear it"
|
||||
)
|
||||
viewModel.smsCodeFirstDigit.value = clip[0].toString()
|
||||
viewModel.smsCodeSecondDigit.value = clip[1].toString()
|
||||
viewModel.smsCodeThirdDigit.value = clip[2].toString()
|
||||
viewModel.smsCodeLastDigit.value = clip[3].toString()
|
||||
clipboard.clearPrimaryClip()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun goBack() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.telephony.TelephonyManager
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.AssistantRegisterFragmentBinding
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.assistant.viewmodel.AccountCreationViewModel
|
||||
import org.linphone.utils.ConfirmationDialogModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.DialogUtils
|
||||
import org.linphone.utils.PhoneNumberUtils
|
||||
import androidx.core.net.toUri
|
||||
|
||||
@UiThread
|
||||
class RegisterFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Register Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantRegisterFragmentBinding
|
||||
|
||||
private val viewModel: AccountCreationViewModel by navGraphViewModels(
|
||||
R.id.assistant_nav_graph
|
||||
)
|
||||
|
||||
private val dropdownListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val dialPlan = viewModel.dialPlansList[position]
|
||||
Log.i(
|
||||
"$TAG Selected dialplan updated [+${dialPlan.countryCallingCode}] / [${dialPlan.country}]"
|
||||
)
|
||||
viewModel.selectedDialPlan.value = dialPlan
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = AssistantRegisterFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
binding.setLoginClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
binding.setOpenSubscribeWebPageClickListener {
|
||||
val url = getString(R.string.web_platform_register_email_url)
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
binding.username.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
viewModel.usernameError.value = ""
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
binding.phoneNumber.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
viewModel.phoneNumberError.value = ""
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
viewModel.normalizedPhoneNumberEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { number ->
|
||||
showPhoneNumberConfirmationDialog(number)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.showPassword.observe(viewLifecycleOwner) {
|
||||
lifecycleScope.launch {
|
||||
delay(50)
|
||||
binding.password.setSelection(binding.password.text?.length ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.goToSmsCodeConfirmationViewEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Going to SMS code confirmation fragment")
|
||||
if (findNavController().currentDestination?.id == R.id.registerFragment) {
|
||||
val action =
|
||||
RegisterFragmentDirections.actionRegisterFragmentToRegisterCodeConfirmationFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val telephonyManager = requireContext().getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
|
||||
val countryIso = telephonyManager.networkCountryIso
|
||||
coreContext.postOnCoreThread {
|
||||
val fragmentContext = context ?: return@postOnCoreThread
|
||||
|
||||
val adapter = object : ArrayAdapter<String>(
|
||||
fragmentContext,
|
||||
R.layout.drop_down_item,
|
||||
viewModel.dialPlansLabelList
|
||||
) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: super.getView(position, null, parent)
|
||||
val label = viewModel.dialPlansShortLabelList[position]
|
||||
(view as? AppCompatTextView)?.text = label
|
||||
return view
|
||||
}
|
||||
}
|
||||
adapter.setDropDownViewResource(R.layout.assistant_country_picker_dropdown_cell)
|
||||
|
||||
val dialPlan = PhoneNumberUtils.getDeviceDialPlan(countryIso)
|
||||
var default = 0
|
||||
if (dialPlan != null) {
|
||||
viewModel.selectedDialPlan.postValue(dialPlan)
|
||||
default = viewModel.dialPlansList.indexOf(dialPlan)
|
||||
}
|
||||
|
||||
coreContext.postOnMainThread {
|
||||
binding.prefix.adapter = adapter
|
||||
binding.prefix.setSelection(default)
|
||||
binding.prefix.onItemSelectedListener = dropdownListener
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun goBack() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun showPhoneNumberConfirmationDialog(number: String) {
|
||||
val label = AppUtils.getFormattedString(R.string.assistant_dialog_confirm_phone_number_message, number)
|
||||
val model = ConfirmationDialogModel(label)
|
||||
val dialog = DialogUtils.getAccountCreationPhoneNumberConfirmationDialog(
|
||||
requireActivity(),
|
||||
model
|
||||
)
|
||||
|
||||
model.dismissEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
model.confirmEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
viewModel.startAccountCreation()
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.telephony.TelephonyManager
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.AssistantThirdPartySipAccountLoginFragmentBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.assistant.viewmodel.ThirdPartySipAccountLoginViewModel
|
||||
import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections
|
||||
import org.linphone.utils.DialogUtils
|
||||
import org.linphone.utils.PhoneNumberUtils
|
||||
|
||||
@UiThread
|
||||
class ThirdPartySipAccountLoginFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Third Party SIP Account Login Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantThirdPartySipAccountLoginFragmentBinding
|
||||
|
||||
private val viewModel: ThirdPartySipAccountLoginViewModel by navGraphViewModels(
|
||||
R.id.assistant_nav_graph
|
||||
)
|
||||
|
||||
private val dropdownListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val transport = viewModel.availableTransports[position]
|
||||
Log.i("$TAG Selected transport updated [$transport]")
|
||||
viewModel.transport.value = transport
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var adapter: ArrayAdapter<String>
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = AssistantThirdPartySipAccountLoginFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
adapter = ArrayAdapter(
|
||||
requireContext(),
|
||||
R.layout.drop_down_item,
|
||||
viewModel.availableTransports
|
||||
)
|
||||
adapter.setDropDownViewResource(R.layout.generic_dropdown_cell)
|
||||
binding.transport.adapter = adapter
|
||||
binding.transport.onItemSelectedListener = dropdownListener
|
||||
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
binding.setOutboundProxyTooltipClickListener {
|
||||
showOutboundProxyInfoDialog()
|
||||
}
|
||||
|
||||
viewModel.showPassword.observe(viewLifecycleOwner) {
|
||||
lifecycleScope.launch {
|
||||
delay(50)
|
||||
binding.password.setSelection(binding.password.text?.length ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.accountLoggedInEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Account successfully logged-in, leaving assistant")
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.accountLoginErrorEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { message ->
|
||||
(requireActivity() as GenericActivity).showRedToast(
|
||||
message,
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.defaultTransportIndexEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { index ->
|
||||
binding.transport.setSelection(index)
|
||||
}
|
||||
}
|
||||
|
||||
coreContext.bearerAuthenticationRequestedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val serverUrl = pair.first
|
||||
val username = pair.second
|
||||
|
||||
Log.i(
|
||||
"$TAG Navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]"
|
||||
)
|
||||
if (findNavController().currentDestination?.id == R.id.thirdPartySipAccountLoginFragment) {
|
||||
val action = SingleSignOnFragmentDirections.actionGlobalSingleSignOnFragment(
|
||||
serverUrl,
|
||||
username
|
||||
)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val telephonyManager = requireContext().getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
|
||||
val countryIso = telephonyManager.networkCountryIso
|
||||
coreContext.postOnCoreThread {
|
||||
val dialPlan = PhoneNumberUtils.getDeviceDialPlan(countryIso)
|
||||
if (dialPlan != null) {
|
||||
viewModel.internationalPrefix.postValue(dialPlan.countryCallingCode)
|
||||
viewModel.internationalPrefixIsoCountryCode.postValue(dialPlan.isoCountryCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun goBack() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun showOutboundProxyInfoDialog() {
|
||||
val dialog = DialogUtils.getAccountOutboundProxyHelpDialog(requireActivity())
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.AssistantThirdPartySipAccountWarningFragmentBinding
|
||||
import org.linphone.ui.GenericFragment
|
||||
import androidx.core.net.toUri
|
||||
|
||||
@UiThread
|
||||
class ThirdPartySipAccountWarningFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Third Party SIP Account Warning Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantThirdPartySipAccountWarningFragmentBinding
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = AssistantThirdPartySipAccountWarningFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
binding.setContactClickListener {
|
||||
val url = getString(R.string.website_contact_url)
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setCreateAccountClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.thirdPartySipAccountWarningFragment) {
|
||||
val action =
|
||||
ThirdPartySipAccountWarningFragmentDirections.actionThirdPartySipAccountWarningFragmentToRegisterFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setLoginClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.thirdPartySipAccountWarningFragment) {
|
||||
val action =
|
||||
ThirdPartySipAccountWarningFragmentDirections.actionThirdPartySipAccountWarningFragmentToThirdPartySipAccountLoginFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun goBack() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.model
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.ClickableSpan
|
||||
import android.view.View
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import java.util.regex.Pattern
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
|
||||
class AcceptConditionsAndPolicyDialogModel
|
||||
@UiThread
|
||||
constructor() {
|
||||
companion object {
|
||||
private const val TAG = "[Accept Terms & Policy Dialog Model]"
|
||||
}
|
||||
|
||||
val message = MutableLiveData<SpannableString>()
|
||||
|
||||
val dismissEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val conditionsAcceptedEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val generalTermsClickedEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val privacyPolicyClickedEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
init {
|
||||
val generalTerms = AppUtils.getString(R.string.assistant_dialog_general_terms_label)
|
||||
val privacyPolicy = AppUtils.getString(R.string.assistant_dialog_privacy_policy_label)
|
||||
val label = coreContext.context.getString(
|
||||
R.string.assistant_dialog_general_terms_and_privacy_policy_message,
|
||||
generalTerms,
|
||||
privacyPolicy
|
||||
)
|
||||
val spannable = SpannableString(label)
|
||||
|
||||
val termsMatcher = Pattern.compile(generalTerms).matcher(label)
|
||||
if (termsMatcher.find()) {
|
||||
val clickableSpan: ClickableSpan = object : ClickableSpan() {
|
||||
override fun onClick(widget: View) {
|
||||
Log.i("$TAG Clicked on general terms link")
|
||||
generalTermsClickedEvent.value = Event(true)
|
||||
}
|
||||
}
|
||||
spannable.setSpan(
|
||||
clickableSpan,
|
||||
termsMatcher.start(0),
|
||||
termsMatcher.end(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
val policyMatcher = Pattern.compile(privacyPolicy).matcher(label)
|
||||
if (policyMatcher.find()) {
|
||||
val clickableSpan: ClickableSpan = object : ClickableSpan() {
|
||||
override fun onClick(widget: View) {
|
||||
Log.i("$TAG Clicked on privacy policy link")
|
||||
privacyPolicyClickedEvent.value = Event(true)
|
||||
}
|
||||
}
|
||||
spannable.setSpan(
|
||||
clickableSpan,
|
||||
policyMatcher.start(0),
|
||||
policyMatcher.end(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
message.value = spannable
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun dismiss() {
|
||||
dismissEvent.value = Event(true)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun acceptConditions() {
|
||||
conditionsAcceptedEvent.value = Event(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,622 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.viewmodel
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Account
|
||||
import org.linphone.core.AccountManagerServices
|
||||
import org.linphone.core.AccountManagerServicesRequest
|
||||
import org.linphone.core.AccountManagerServicesRequestListenerStub
|
||||
import org.linphone.core.AuthInfo
|
||||
import org.linphone.core.Core
|
||||
import org.linphone.core.CoreListenerStub
|
||||
import org.linphone.core.DialPlan
|
||||
import org.linphone.core.Dictionary
|
||||
import org.linphone.core.Factory
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
class AccountCreationViewModel
|
||||
@UiThread
|
||||
constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Account Creation ViewModel]"
|
||||
|
||||
private const val TIME_TO_WAIT_FOR_PUSH_NOTIFICATION_WITH_ACCOUNT_CREATION_TOKEN = 5000
|
||||
private const val HASH_ALGORITHM = "SHA-256"
|
||||
}
|
||||
|
||||
val username = MutableLiveData<String>()
|
||||
|
||||
val usernameError = MutableLiveData<String>()
|
||||
|
||||
val password = MutableLiveData<String>()
|
||||
|
||||
val passwordError = MutableLiveData<String>()
|
||||
|
||||
val phoneNumber = MutableLiveData<String>()
|
||||
|
||||
val phoneNumberError = MutableLiveData<String>()
|
||||
|
||||
val dialPlansLabelList = arrayListOf<String>()
|
||||
|
||||
val dialPlansShortLabelList = arrayListOf<String>()
|
||||
|
||||
val dialPlansList = arrayListOf<DialPlan>()
|
||||
|
||||
val selectedDialPlan = MutableLiveData<DialPlan>()
|
||||
|
||||
val showPassword = MutableLiveData<Boolean>()
|
||||
|
||||
val lockUsernameAndPassword = MutableLiveData<Boolean>()
|
||||
|
||||
val createEnabled = MediatorLiveData<Boolean>()
|
||||
|
||||
val pushNotificationsAvailable = MutableLiveData<Boolean>()
|
||||
|
||||
val confirmationMessage = MutableLiveData<String>()
|
||||
|
||||
val smsCodeFirstDigit = MutableLiveData<String>()
|
||||
val smsCodeSecondDigit = MutableLiveData<String>()
|
||||
val smsCodeThirdDigit = MutableLiveData<String>()
|
||||
val smsCodeLastDigit = MutableLiveData<String>()
|
||||
|
||||
val operationInProgress = MutableLiveData<Boolean>()
|
||||
|
||||
private var normalizedPhoneNumber: String? = null
|
||||
val normalizedPhoneNumberEvent = MutableLiveData<Event<String>>()
|
||||
|
||||
val goToSmsCodeConfirmationViewEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val accountCreatedEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val accountRecoveryTokenReceivedEvent: MutableLiveData<Event<String>> by lazy {
|
||||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
||||
private var waitingForFlexiApiPushToken = false
|
||||
private var waitForPushJob: Job? = null
|
||||
|
||||
private lateinit var accountManagerServices: AccountManagerServices
|
||||
private var requestedTokenIsForAccountCreation: Boolean = true
|
||||
private var accountCreationToken: String? = null
|
||||
private var accountRecoveryToken: String? = null
|
||||
|
||||
private var accountCreatedAuthInfo: AuthInfo? = null
|
||||
private var accountCreated: Account? = null
|
||||
|
||||
private val accountManagerServicesListener = object : AccountManagerServicesRequestListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onRequestSuccessful(
|
||||
request: AccountManagerServicesRequest,
|
||||
data: String?
|
||||
) {
|
||||
Log.i("$TAG Request [${request.type}] was successful, data is [$data]")
|
||||
operationInProgress.postValue(false)
|
||||
|
||||
when (request.type) {
|
||||
AccountManagerServicesRequest.Type.CreateAccountUsingToken -> {
|
||||
if (!data.isNullOrEmpty()) {
|
||||
storeAccountInCore(data)
|
||||
sendCodeBySms()
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG No data found for createAccountUsingToken request, can't continue!"
|
||||
)
|
||||
}
|
||||
}
|
||||
AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush,
|
||||
AccountManagerServicesRequest.Type.SendAccountRecoveryTokenByPush -> {
|
||||
Log.i("$TAG Send token by push notification request has been accepted, it should be received soon")
|
||||
}
|
||||
AccountManagerServicesRequest.Type.SendPhoneNumberLinkingCodeBySms -> {
|
||||
goToSmsCodeConfirmationViewEvent.postValue(Event(true))
|
||||
}
|
||||
AccountManagerServicesRequest.Type.LinkPhoneNumberUsingCode -> {
|
||||
enableAccountAndSetItAsDefault()
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onRequestError(
|
||||
request: AccountManagerServicesRequest,
|
||||
statusCode: Int,
|
||||
errorMessage: String?,
|
||||
parameterErrors: Dictionary?
|
||||
) {
|
||||
Log.e(
|
||||
"$TAG Request [${request.type}] returned an error with status code [$statusCode] and message [$errorMessage]"
|
||||
)
|
||||
operationInProgress.postValue(false)
|
||||
|
||||
if (!errorMessage.isNullOrEmpty()) {
|
||||
showFormattedRedToast(errorMessage, R.drawable.warning_circle)
|
||||
}
|
||||
|
||||
for (parameter in parameterErrors?.keys.orEmpty()) {
|
||||
val parameterErrorMessage = parameterErrors?.getString(parameter) ?: ""
|
||||
when (parameter) {
|
||||
"username" -> usernameError.postValue(parameterErrorMessage)
|
||||
"password" -> passwordError.postValue(parameterErrorMessage)
|
||||
"phone" -> phoneNumberError.postValue(parameterErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
when (request.type) {
|
||||
AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush,
|
||||
AccountManagerServicesRequest.Type.SendAccountRecoveryTokenByPush -> {
|
||||
Log.w("$TAG Cancelling job waiting for push notification")
|
||||
waitingForFlexiApiPushToken = false
|
||||
waitForPushJob?.cancel()
|
||||
}
|
||||
AccountManagerServicesRequest.Type.SendPhoneNumberLinkingCodeBySms -> {
|
||||
val authInfo = accountCreatedAuthInfo
|
||||
if (authInfo != null) {
|
||||
coreContext.core.removeAuthInfo(authInfo)
|
||||
}
|
||||
val account = accountCreated
|
||||
if (account != null) {
|
||||
coreContext.core.removeAccount(account)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
createEnabled.postValue(true)
|
||||
}
|
||||
}
|
||||
|
||||
private val coreListener = object : CoreListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onPushNotificationReceived(core: Core, payload: String?) {
|
||||
Log.i("$TAG Push received: [$payload]")
|
||||
|
||||
val data = payload.orEmpty()
|
||||
if (data.isNotEmpty()) {
|
||||
try {
|
||||
// This is because JSONObject.toString() done by the SDK will result in payload looking like {"custom-payload":"{\"token\":\"value\"}"}
|
||||
val cleanPayload = data
|
||||
.replace("\\\"", "\"")
|
||||
.replace("\"{", "{")
|
||||
.replace("}\"", "}")
|
||||
Log.i("$TAG Cleaned payload is: [$cleanPayload]")
|
||||
|
||||
val json = JSONObject(cleanPayload)
|
||||
val customPayload = json.getJSONObject("custom-payload")
|
||||
if (customPayload.has("token")) {
|
||||
waitForPushJob?.cancel()
|
||||
waitingForFlexiApiPushToken = false
|
||||
operationInProgress.postValue(false)
|
||||
|
||||
val token = customPayload.getString("token")
|
||||
if (token.isNotEmpty()) {
|
||||
if (requestedTokenIsForAccountCreation) {
|
||||
accountCreationToken = token
|
||||
Log.i(
|
||||
"$TAG Extracted token [$accountCreationToken] from push payload, creating account"
|
||||
)
|
||||
createAccount()
|
||||
} else {
|
||||
accountRecoveryToken = token
|
||||
Log.i(
|
||||
"$TAG Extracted token [$accountRecoveryToken] from push payload, opening browser"
|
||||
)
|
||||
accountRecoveryTokenReceivedEvent.postValue(Event(token))
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Push payload JSON object has an empty 'token'!")
|
||||
onFlexiApiTokenRequestError()
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Push payload JSON object has no 'token' key!")
|
||||
onFlexiApiTokenRequestError()
|
||||
}
|
||||
} catch (e: JSONException) {
|
||||
Log.e("$TAG Exception trying to parse push payload as JSON: [$e]")
|
||||
onFlexiApiTokenRequestError()
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Push payload is null or empty, can't extract auth token!")
|
||||
onFlexiApiTokenRequestError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
operationInProgress.value = false
|
||||
lockUsernameAndPassword.value = false
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
pushNotificationsAvailable.postValue(LinphoneUtils.arePushNotificationsAvailable(core))
|
||||
|
||||
val dialPlans = Factory.instance().dialPlans.toList()
|
||||
for (dialPlan in dialPlans) {
|
||||
dialPlansList.add(dialPlan)
|
||||
dialPlansLabelList.add(
|
||||
"${dialPlan.flag} ${dialPlan.country} | +${dialPlan.countryCallingCode}"
|
||||
)
|
||||
dialPlansShortLabelList.add(
|
||||
"${dialPlan.flag} +${dialPlan.countryCallingCode}"
|
||||
)
|
||||
}
|
||||
|
||||
accountManagerServices = core.createAccountManagerServices()
|
||||
accountManagerServices.language = Locale.getDefault().language // Returns en, fr, etc...
|
||||
core.addListener(coreListener)
|
||||
}
|
||||
|
||||
showPassword.value = false
|
||||
|
||||
createEnabled.addSource(username) {
|
||||
createEnabled.value = isCreateButtonEnabled()
|
||||
}
|
||||
createEnabled.addSource(password) {
|
||||
createEnabled.value = isCreateButtonEnabled()
|
||||
}
|
||||
createEnabled.addSource(selectedDialPlan) {
|
||||
createEnabled.value = isCreateButtonEnabled()
|
||||
}
|
||||
createEnabled.addSource(phoneNumber) {
|
||||
createEnabled.value = isCreateButtonEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
override fun onCleared() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.removeListener(coreListener)
|
||||
}
|
||||
waitForPushJob?.cancel()
|
||||
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun askUserToConfirmPhoneNumber() {
|
||||
coreContext.postOnCoreThread {
|
||||
if (::accountManagerServices.isInitialized) {
|
||||
val dialPlan = selectedDialPlan.value
|
||||
if (dialPlan == null) {
|
||||
Log.e("$TAG No dial plan (country) selected!")
|
||||
return@postOnCoreThread
|
||||
}
|
||||
val number = phoneNumber.value.orEmpty().trim()
|
||||
val formattedPhoneNumber = dialPlan.formatPhoneNumber(number, false)
|
||||
Log.i(
|
||||
"$TAG Formatted phone number [$number] using dial plan [${dialPlan.country}] is [$formattedPhoneNumber]"
|
||||
)
|
||||
|
||||
val message = coreContext.context.getString(
|
||||
R.string.assistant_account_creation_sms_confirmation_explanation,
|
||||
formattedPhoneNumber
|
||||
)
|
||||
normalizedPhoneNumber = formattedPhoneNumber
|
||||
confirmationMessage.postValue(message)
|
||||
normalizedPhoneNumberEvent.postValue(Event(formattedPhoneNumber))
|
||||
} else {
|
||||
Log.e("$TAG Account manager services hasn't been initialized!")
|
||||
showRedToast(R.string.assistant_account_register_unexpected_error, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun startAccountCreation() {
|
||||
operationInProgress.value = true
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
if (accountCreationToken.isNullOrEmpty()) {
|
||||
Log.i("$TAG We don't have an account creation token yet, let's request one")
|
||||
requestFlexiApiToken(requestAccountCreationToken = true)
|
||||
} else {
|
||||
val authInfo = accountCreatedAuthInfo
|
||||
if (authInfo != null) {
|
||||
Log.i("$TAG Account has already been created, requesting SMS to be sent")
|
||||
sendCodeBySms()
|
||||
} else {
|
||||
Log.i("$TAG We've already have a token [$accountCreationToken], continuing")
|
||||
createAccount()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun requestAccountRecoveryToken() {
|
||||
coreContext.postOnCoreThread {
|
||||
val existingToken = accountRecoveryToken
|
||||
if (existingToken.isNullOrEmpty()) {
|
||||
Log.i("$TAG We don't have an account recovery token yet, let's request one")
|
||||
requestFlexiApiToken(requestAccountCreationToken = false)
|
||||
} else {
|
||||
Log.i("$TAG We've already have a token [$existingToken], using it")
|
||||
accountRecoveryTokenReceivedEvent.postValue(Event(existingToken))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun toggleShowPassword() {
|
||||
showPassword.value = showPassword.value == false
|
||||
}
|
||||
|
||||
@UiThread
|
||||
private fun isCreateButtonEnabled(): Boolean {
|
||||
return username.value.orEmpty().isNotEmpty() && password.value.orEmpty().isNotEmpty() && phoneNumber.value.orEmpty().isNotEmpty() && selectedDialPlan.value?.countryCallingCode.orEmpty().isNotEmpty()
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun validateCode() {
|
||||
usernameError.postValue("")
|
||||
passwordError.postValue("")
|
||||
phoneNumberError.postValue("")
|
||||
operationInProgress.value = true
|
||||
|
||||
val account = accountCreated
|
||||
if (::accountManagerServices.isInitialized && account != null) {
|
||||
val code =
|
||||
"${smsCodeFirstDigit.value.orEmpty().trim()}${smsCodeSecondDigit.value.orEmpty().trim()}${smsCodeThirdDigit.value.orEmpty().trim()}${smsCodeLastDigit.value.orEmpty().trim()}"
|
||||
val identity = account.params.identityAddress
|
||||
if (identity != null) {
|
||||
Log.i(
|
||||
"$TAG Activating account using code [$code] for account [${identity.asStringUriOnly()}]"
|
||||
)
|
||||
val request = accountManagerServices.createLinkPhoneNumberToAccountUsingCodeRequest(
|
||||
identity,
|
||||
code
|
||||
)
|
||||
request.addListener(accountManagerServicesListener)
|
||||
request.submit()
|
||||
|
||||
// Reset code
|
||||
smsCodeFirstDigit.postValue("")
|
||||
smsCodeSecondDigit.postValue("")
|
||||
smsCodeThirdDigit.postValue("")
|
||||
smsCodeLastDigit.postValue("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun sendCodeBySms() {
|
||||
usernameError.postValue("")
|
||||
passwordError.postValue("")
|
||||
phoneNumberError.postValue("")
|
||||
|
||||
val account = accountCreated
|
||||
if (::accountManagerServices.isInitialized && account != null) {
|
||||
val phoneNumberValue = normalizedPhoneNumber
|
||||
if (phoneNumberValue.isNullOrEmpty()) {
|
||||
Log.e("$TAG Phone number is null or empty, this shouldn't happen at this step!")
|
||||
return
|
||||
}
|
||||
|
||||
operationInProgress.postValue(true)
|
||||
createEnabled.postValue(false)
|
||||
|
||||
val identity = account.params.identityAddress
|
||||
if (identity != null) {
|
||||
Log.i(
|
||||
"$TAG Account [${identity.asStringUriOnly()}] should now be created, asking account manager to send a confirmation code by SMS to [$phoneNumberValue]"
|
||||
)
|
||||
val request = accountManagerServices.createSendPhoneNumberLinkingCodeBySmsRequest(
|
||||
identity,
|
||||
phoneNumberValue
|
||||
)
|
||||
request.addListener(accountManagerServicesListener)
|
||||
request.submit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun createAccount() {
|
||||
usernameError.postValue("")
|
||||
passwordError.postValue("")
|
||||
phoneNumberError.postValue("")
|
||||
|
||||
if (::accountManagerServices.isInitialized) {
|
||||
val token = accountCreationToken
|
||||
if (token.isNullOrEmpty()) {
|
||||
Log.e("$TAG No account creation token, can't create account!")
|
||||
return
|
||||
}
|
||||
|
||||
operationInProgress.postValue(true)
|
||||
createEnabled.postValue(false)
|
||||
|
||||
val usernameValue = username.value.orEmpty().trim()
|
||||
val passwordValue = password.value.orEmpty().trim()
|
||||
if (usernameValue.isEmpty() || passwordValue.isEmpty()) {
|
||||
Log.e("$TAG Either username [$usernameValue] or password is null or empty!")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(
|
||||
"$TAG Account creation token is [$token], creating account with username [$usernameValue] and algorithm SHA-256"
|
||||
)
|
||||
val request = accountManagerServices.createNewAccountUsingTokenRequest(
|
||||
usernameValue,
|
||||
passwordValue,
|
||||
HASH_ALGORITHM,
|
||||
token
|
||||
)
|
||||
request.addListener(accountManagerServicesListener)
|
||||
request.submit()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun storeAccountInCore(identity: String) {
|
||||
val core = coreContext.core
|
||||
core.loadConfigFromXml(corePreferences.linphoneDefaultValuesPath)
|
||||
|
||||
val sipIdentity = Factory.instance().createAddress(identity)
|
||||
if (sipIdentity == null) {
|
||||
Log.e("$TAG Failed to create address from SIP Identity [$identity]!")
|
||||
return
|
||||
}
|
||||
|
||||
val passwordValue = password.value
|
||||
// We need to have an AuthInfo for newly created account to authorize phone number linking request
|
||||
val authInfo = Factory.instance().createAuthInfo(
|
||||
sipIdentity.username.orEmpty(),
|
||||
null,
|
||||
passwordValue,
|
||||
null,
|
||||
null,
|
||||
sipIdentity.domain
|
||||
)
|
||||
core.addAuthInfo(authInfo)
|
||||
Log.i("$TAG Auth info for SIP identity [${sipIdentity.asStringUriOnly()}] created & added")
|
||||
|
||||
val dialPlan = selectedDialPlan.value
|
||||
val accountParams = core.createAccountParams()
|
||||
accountParams.identityAddress = sipIdentity
|
||||
if (dialPlan != null) {
|
||||
Log.i(
|
||||
"$TAG Setting international prefix [${dialPlan.internationalCallPrefix}] and country [${dialPlan.isoCountryCode}] to account params"
|
||||
)
|
||||
accountParams.internationalPrefix = dialPlan.internationalCallPrefix
|
||||
accountParams.internationalPrefixIsoCountryCode = dialPlan.isoCountryCode
|
||||
|
||||
// Do not enable account just yet, wait for it to be activated using SMS code
|
||||
accountParams.isRegisterEnabled = false
|
||||
}
|
||||
val account = core.createAccount(accountParams)
|
||||
core.addAccount(account)
|
||||
Log.i("$TAG Account for SIP identity [${sipIdentity.asStringUriOnly()}] created & added")
|
||||
|
||||
accountCreatedAuthInfo = authInfo
|
||||
accountCreated = account
|
||||
|
||||
lockUsernameAndPassword.postValue(true)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun enableAccountAndSetItAsDefault() {
|
||||
val account = accountCreated ?: return
|
||||
Log.i(
|
||||
"$TAG Account [${account.params.identityAddress?.asStringUriOnly()}] has been created & activated, enable it & setting it as default"
|
||||
)
|
||||
|
||||
val newParams = account.params.clone()
|
||||
newParams.isRegisterEnabled = true
|
||||
account.params = newParams
|
||||
|
||||
coreContext.core.defaultAccount = account
|
||||
accountCreatedEvent.postValue(Event(true))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun requestFlexiApiToken(requestAccountCreationToken: Boolean) {
|
||||
requestedTokenIsForAccountCreation = requestAccountCreationToken
|
||||
if (!coreContext.core.isPushNotificationAvailable) {
|
||||
Log.e(
|
||||
"$TAG Core says push notification aren't available, can't request a token from FlexiAPI"
|
||||
)
|
||||
onFlexiApiTokenRequestError()
|
||||
return
|
||||
}
|
||||
|
||||
operationInProgress.postValue(true)
|
||||
createEnabled.postValue(false)
|
||||
|
||||
val pushConfig = coreContext.core.pushNotificationConfig
|
||||
if (pushConfig != null) {
|
||||
val provider = pushConfig.provider
|
||||
val param = pushConfig.param
|
||||
val prid = pushConfig.prid
|
||||
if (provider.isNullOrEmpty() || param.isNullOrEmpty() || prid.isNullOrEmpty()) {
|
||||
Log.e(
|
||||
"$TAG At least one mandatory push information (provider [$provider], param [$param], prid [$prid]) is missing!"
|
||||
)
|
||||
onFlexiApiTokenRequestError()
|
||||
return
|
||||
}
|
||||
|
||||
// Request an auth token, will be sent by push
|
||||
val request = if (requestAccountCreationToken) {
|
||||
Log.i("$TAG Requesting account creation token")
|
||||
accountManagerServices.createSendAccountCreationTokenByPushRequest(
|
||||
provider,
|
||||
param,
|
||||
prid
|
||||
)
|
||||
} else {
|
||||
Log.i("$TAG Requesting account recovery token")
|
||||
accountManagerServices.createSendAccountRecoveryTokenByPushRequest(
|
||||
provider,
|
||||
param,
|
||||
prid
|
||||
)
|
||||
}
|
||||
request.addListener(accountManagerServicesListener)
|
||||
request.submit()
|
||||
|
||||
val waitFor = TIME_TO_WAIT_FOR_PUSH_NOTIFICATION_WITH_ACCOUNT_CREATION_TOKEN
|
||||
waitingForFlexiApiPushToken = true
|
||||
waitForPushJob?.cancel()
|
||||
|
||||
Log.i("$TAG Waiting push with auth token for $waitFor ms")
|
||||
waitForPushJob = viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
delay(waitFor.toLong())
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (waitingForFlexiApiPushToken) {
|
||||
waitingForFlexiApiPushToken = false
|
||||
Log.e("$TAG Auth token wasn't received by push in [$waitFor] ms")
|
||||
onFlexiApiTokenRequestError()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG No push configuration object in Core, shouldn't happen!")
|
||||
onFlexiApiTokenRequestError()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun onFlexiApiTokenRequestError() {
|
||||
Log.e("$TAG Flexi API token request by push error!")
|
||||
operationInProgress.postValue(false)
|
||||
showRedToast(R.string.assistant_account_register_push_notification_not_received_error, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.viewmodel
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Account
|
||||
import org.linphone.core.AuthInfo
|
||||
import org.linphone.core.Core
|
||||
import org.linphone.core.CoreListenerStub
|
||||
import org.linphone.core.Factory
|
||||
import org.linphone.core.Reason
|
||||
import org.linphone.core.RegistrationState
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
|
||||
open class AccountLoginViewModel
|
||||
@UiThread
|
||||
constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Account Login ViewModel]"
|
||||
}
|
||||
|
||||
val showBackButton = MutableLiveData<Boolean>()
|
||||
|
||||
val hideCreateAccount = MutableLiveData<Boolean>()
|
||||
|
||||
val hideScanQrCode = MutableLiveData<Boolean>()
|
||||
|
||||
val hideThirdPartyAccount = MutableLiveData<Boolean>()
|
||||
|
||||
val sipIdentity = MutableLiveData<String>()
|
||||
|
||||
val password = MutableLiveData<String>()
|
||||
|
||||
val internationalPrefix = MutableLiveData<String>()
|
||||
|
||||
val internationalPrefixIsoCountryCode = MutableLiveData<String>()
|
||||
|
||||
val showPassword = MutableLiveData<Boolean>()
|
||||
|
||||
val loginEnabled = MediatorLiveData<Boolean>()
|
||||
|
||||
val registrationInProgress = MutableLiveData<Boolean>()
|
||||
|
||||
val accountLoggedInEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
val accountLoginErrorEvent: MutableLiveData<Event<String>> by lazy {
|
||||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
||||
val skipLandingToThirdPartySipAccountEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
var conditionsAndPrivacyPolicyAccepted = false
|
||||
|
||||
private lateinit var newlyCreatedAuthInfo: AuthInfo
|
||||
private lateinit var newlyCreatedAccount: Account
|
||||
|
||||
private val coreListener = object : CoreListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onAccountRegistrationStateChanged(
|
||||
core: Core,
|
||||
account: Account,
|
||||
state: RegistrationState?,
|
||||
message: String
|
||||
) {
|
||||
if (account == newlyCreatedAccount) {
|
||||
Log.i("$TAG Newly created account registration state is [$state] ($message)")
|
||||
|
||||
if (state == RegistrationState.Ok) {
|
||||
registrationInProgress.postValue(false)
|
||||
core.removeListener(this)
|
||||
|
||||
// Set new account as default
|
||||
core.defaultAccount = newlyCreatedAccount
|
||||
accountLoggedInEvent.postValue(Event(core.accountList.size == 1))
|
||||
} else if (state == RegistrationState.Failed) {
|
||||
registrationInProgress.postValue(false)
|
||||
core.removeListener(this)
|
||||
|
||||
val error = when (account.error) {
|
||||
Reason.Forbidden -> {
|
||||
AppUtils.getString(R.string.assistant_account_login_forbidden_error)
|
||||
}
|
||||
else -> {
|
||||
AppUtils.getFormattedString(
|
||||
R.string.assistant_account_login_error,
|
||||
account.error.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
accountLoginErrorEvent.postValue(Event(error))
|
||||
|
||||
Log.e("$TAG Account failed to REGISTER [$message], removing it")
|
||||
core.removeAuthInfo(newlyCreatedAuthInfo)
|
||||
core.removeAccount(newlyCreatedAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
// Prevent user from leaving assistant if no account was configured yet
|
||||
showBackButton.postValue(core.accountList.isNotEmpty())
|
||||
hideCreateAccount.postValue(corePreferences.hideAssistantCreateAccount)
|
||||
hideScanQrCode.postValue(corePreferences.hideAssistantScanQrCode)
|
||||
hideThirdPartyAccount.postValue(corePreferences.hideAssistantThirdPartySipAccount)
|
||||
conditionsAndPrivacyPolicyAccepted = corePreferences.conditionsAndPrivacyPolicyAccepted
|
||||
|
||||
if (corePreferences.assistantDirectlyGoToThirdPartySipAccountLogin) {
|
||||
skipLandingToThirdPartySipAccountEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
|
||||
showPassword.value = false
|
||||
registrationInProgress.value = false
|
||||
|
||||
loginEnabled.addSource(sipIdentity) {
|
||||
loginEnabled.value = isLoginButtonEnabled()
|
||||
}
|
||||
loginEnabled.addSource(password) {
|
||||
loginEnabled.value = isLoginButtonEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun login() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.loadConfigFromXml(corePreferences.linphoneDefaultValuesPath)
|
||||
|
||||
val userInput = sipIdentity.value.orEmpty().trim()
|
||||
val defaultDomain = corePreferences.defaultDomain
|
||||
val identity = if (userInput.startsWith("sip:")) {
|
||||
if (userInput.contains("@")) {
|
||||
userInput
|
||||
} else {
|
||||
"$userInput@$defaultDomain"
|
||||
}
|
||||
} else {
|
||||
if (userInput.contains("@")) {
|
||||
"sip:$userInput"
|
||||
} else {
|
||||
"sip:$userInput@$defaultDomain"
|
||||
}
|
||||
}
|
||||
Log.i("$TAG Computed identity is [$identity] from user input [$userInput]")
|
||||
|
||||
val identityAddress = Factory.instance().createAddress(identity)
|
||||
if (identityAddress == null) {
|
||||
Log.e("$TAG Can't parse [$identity] as Address!")
|
||||
showRedToast(R.string.assistant_login_cant_parse_address_toast, R.drawable.warning_circle)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
val accounts = core.accountList
|
||||
val found = accounts.find {
|
||||
it.params.identityAddress?.weakEqual(identityAddress) == true
|
||||
}
|
||||
if (found != null) {
|
||||
Log.w("$TAG An account with the same identity address [${identityAddress.asStringUriOnly()}] already exists, do not add it again!")
|
||||
showRedToast(R.string.assistant_account_login_already_connected_error, R.drawable.warning_circle)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
val user = identityAddress.username
|
||||
if (user == null) {
|
||||
Log.e(
|
||||
"$TAG Address [${identityAddress.asStringUriOnly()}] doesn't contains an username!"
|
||||
)
|
||||
showRedToast(R.string.assistant_login_address_without_username_toast, R.drawable.warning_circle)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
val domain = identityAddress.domain
|
||||
|
||||
newlyCreatedAuthInfo = Factory.instance().createAuthInfo(
|
||||
user,
|
||||
null,
|
||||
password.value.orEmpty().trim(),
|
||||
null,
|
||||
null,
|
||||
domain
|
||||
)
|
||||
core.addAuthInfo(newlyCreatedAuthInfo)
|
||||
|
||||
val accountParams = core.createAccountParams()
|
||||
accountParams.identityAddress = identityAddress
|
||||
|
||||
val prefix = internationalPrefix.value.orEmpty().trim()
|
||||
val isoCountryCode = internationalPrefixIsoCountryCode.value.orEmpty()
|
||||
if (prefix.isNotEmpty()) {
|
||||
val prefixDigits = if (prefix.startsWith("+")) {
|
||||
prefix.substring(1)
|
||||
} else {
|
||||
prefix
|
||||
}
|
||||
if (prefixDigits.isNotEmpty()) {
|
||||
Log.i(
|
||||
"$TAG Setting international prefix [$prefixDigits]($isoCountryCode) in account params"
|
||||
)
|
||||
accountParams.internationalPrefix = prefixDigits
|
||||
accountParams.internationalPrefixIsoCountryCode = isoCountryCode
|
||||
}
|
||||
}
|
||||
|
||||
newlyCreatedAccount = core.createAccount(accountParams)
|
||||
|
||||
registrationInProgress.postValue(true)
|
||||
core.addListener(coreListener)
|
||||
Log.i(
|
||||
"$TAG Trying to log in account with SIP identity [${identityAddress.asStringUriOnly()}]"
|
||||
)
|
||||
core.addAccount(newlyCreatedAccount)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun toggleShowPassword() {
|
||||
showPassword.value = showPassword.value == false
|
||||
}
|
||||
|
||||
@UiThread
|
||||
private fun isLoginButtonEnabled(): Boolean {
|
||||
return sipIdentity.value.orEmpty().trim().isNotEmpty() && password.value.orEmpty().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.viewmodel
|
||||
|
||||
import android.Manifest
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
|
||||
class PermissionsViewModel
|
||||
@UiThread
|
||||
constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Permissions ViewModel]"
|
||||
}
|
||||
|
||||
val cameraPermissionGranted = MutableLiveData<Boolean>()
|
||||
|
||||
val recordAudioPermissionGranted = MutableLiveData<Boolean>()
|
||||
|
||||
val readContactsPermissionGranted = MutableLiveData<Boolean>()
|
||||
|
||||
val postNotificationsPermissionGranted = MutableLiveData<Boolean>()
|
||||
|
||||
fun setPermissionGranted(permission: String, granted: Boolean) {
|
||||
Log.i("$TAG Permission [$permission] is ${if (granted) "granted" else "not granted yet/denied"}")
|
||||
when (permission) {
|
||||
Manifest.permission.READ_CONTACTS -> readContactsPermissionGranted.postValue(granted)
|
||||
Manifest.permission.RECORD_AUDIO -> recordAudioPermissionGranted.postValue(granted)
|
||||
Manifest.permission.CAMERA -> cameraPermissionGranted.postValue(granted)
|
||||
Manifest.permission.POST_NOTIFICATIONS -> postNotificationsPermissionGranted.postValue(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.viewmodel
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.ConfiguringState
|
||||
import org.linphone.core.Core
|
||||
import org.linphone.core.CoreListenerStub
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.R
|
||||
import org.linphone.core.GlobalState
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
class QrCodeViewModel
|
||||
@UiThread
|
||||
constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Qr Code Scanner ViewModel]"
|
||||
}
|
||||
|
||||
val remoteProvisioningSuccessfulEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val onErrorEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
private val coreListener = object : CoreListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onConfiguringStatus(core: Core, status: ConfiguringState, message: String?) {
|
||||
Log.i("$TAG Configuring state is [$status]")
|
||||
if (status == ConfiguringState.Failed) {
|
||||
Log.e("$TAG Failure applying remote provisioning: $message")
|
||||
showRedToast(R.string.remote_provisioning_config_failed_toast, R.drawable.warning_circle)
|
||||
onErrorEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onGlobalStateChanged(core: Core, state: GlobalState?, message: String) {
|
||||
if (state == GlobalState.On) {
|
||||
if (core.accountList.isEmpty()) {
|
||||
Log.w("$TAG Provisioning was successful but no account has been configured yet, staying in assistant")
|
||||
// Remote provisioning didn't contain any account
|
||||
// and there wasn't at least one configured before either
|
||||
remoteProvisioningSuccessfulEvent.postValue(Event(false))
|
||||
} else {
|
||||
Log.i("$TAG At least an account exists in Core, leaving assistant")
|
||||
remoteProvisioningSuccessfulEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onQrcodeFound(core: Core, result: String?) {
|
||||
Log.i("$TAG QR Code found: [$result]")
|
||||
if (result == null) {
|
||||
showRedToast(R.string.assistant_qr_code_invalid_toast, R.drawable.warning_circle)
|
||||
} else {
|
||||
val url = LinphoneUtils.getRemoteProvisioningUrlFromUri(result)
|
||||
if (url == null) {
|
||||
Log.e("$TAG The content of the QR Code [$result] doesn't seem to be a valid web URL")
|
||||
showRedToast(R.string.assistant_qr_code_invalid_toast, R.drawable.warning_circle)
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(
|
||||
"$TAG Setting QR code URL [$url], restarting the Core outside of iterate() loop to apply configuration changes"
|
||||
)
|
||||
core.nativePreviewWindowId = null
|
||||
core.isVideoPreviewEnabled = false
|
||||
core.isQrcodeVideoPreviewEnabled = false
|
||||
core.provisioningUri = url
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Stopping Core")
|
||||
core.stop()
|
||||
Log.i("$TAG Core has been stopped, restarting it")
|
||||
core.start()
|
||||
Log.i("$TAG Core has been restarted")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.addListener(coreListener)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
override fun onCleared() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.removeListener(coreListener)
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun setBackCamera() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
// Just in case, on some devices such as Xiaomi Redmi Note 5
|
||||
// this is required right after granting the CAMERA permission
|
||||
core.reloadVideoDevices()
|
||||
|
||||
if (!coreContext.setBackCamera()) {
|
||||
for (camera in core.videoDevicesList) {
|
||||
if (camera != "StaticImage: Static picture") {
|
||||
Log.w("$TAG No back facing camera found, using first one available [$camera]")
|
||||
coreContext.core.videoDevice = camera
|
||||
return@postOnCoreThread
|
||||
}
|
||||
}
|
||||
|
||||
Log.e("$TAG No camera device found!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.viewmodel
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import java.util.Locale
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Account
|
||||
import org.linphone.core.AuthInfo
|
||||
import org.linphone.core.Core
|
||||
import org.linphone.core.CoreListenerStub
|
||||
import org.linphone.core.Factory
|
||||
import org.linphone.core.Reason
|
||||
import org.linphone.core.RegistrationState
|
||||
import org.linphone.core.TransportType
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
|
||||
class ThirdPartySipAccountLoginViewModel
|
||||
@UiThread
|
||||
constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Third Party SIP Account Login ViewModel]"
|
||||
}
|
||||
|
||||
val username = MutableLiveData<String>()
|
||||
|
||||
val authId = MutableLiveData<String>()
|
||||
|
||||
val password = MutableLiveData<String>()
|
||||
|
||||
val domain = MutableLiveData<String>()
|
||||
|
||||
val displayName = MutableLiveData<String>()
|
||||
|
||||
val transport = MutableLiveData<String>()
|
||||
|
||||
val internationalPrefix = MutableLiveData<String>()
|
||||
|
||||
val internationalPrefixIsoCountryCode = MutableLiveData<String>()
|
||||
|
||||
val showPassword = MutableLiveData<Boolean>()
|
||||
|
||||
val expandAdvancedSettings = MutableLiveData<Boolean>()
|
||||
|
||||
val proxy = MutableLiveData<String>()
|
||||
|
||||
val outboundProxy = MutableLiveData<String>()
|
||||
|
||||
val loginEnabled = MediatorLiveData<Boolean>()
|
||||
|
||||
val registrationInProgress = MutableLiveData<Boolean>()
|
||||
|
||||
val accountLoggedInEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
val accountLoginErrorEvent: MutableLiveData<Event<String>> by lazy {
|
||||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
||||
val defaultTransportIndexEvent: MutableLiveData<Event<Int>> by lazy {
|
||||
MutableLiveData<Event<Int>>()
|
||||
}
|
||||
|
||||
val availableTransports = arrayListOf<String>()
|
||||
|
||||
private lateinit var newlyCreatedAuthInfo: AuthInfo
|
||||
private lateinit var newlyCreatedAccount: Account
|
||||
|
||||
private val coreListener = object : CoreListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onAccountRegistrationStateChanged(
|
||||
core: Core,
|
||||
account: Account,
|
||||
state: RegistrationState?,
|
||||
message: String
|
||||
) {
|
||||
if (account == newlyCreatedAccount) {
|
||||
Log.i("$TAG Newly created account registration state is [$state] ($message)")
|
||||
|
||||
if (state == RegistrationState.Ok) {
|
||||
registrationInProgress.postValue(false)
|
||||
core.removeListener(this)
|
||||
|
||||
// Set new account as default
|
||||
core.defaultAccount = newlyCreatedAccount
|
||||
accountLoggedInEvent.postValue(Event(true))
|
||||
} else if (state == RegistrationState.Failed) {
|
||||
registrationInProgress.postValue(false)
|
||||
core.removeListener(this)
|
||||
|
||||
val error = when (account.error) {
|
||||
Reason.Forbidden -> {
|
||||
AppUtils.getString(R.string.assistant_account_login_forbidden_error)
|
||||
}
|
||||
else -> {
|
||||
AppUtils.getFormattedString(
|
||||
R.string.assistant_account_login_error,
|
||||
account.error.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
accountLoginErrorEvent.postValue(Event(error))
|
||||
|
||||
Log.e("$TAG Account failed to REGISTER [$message], removing it")
|
||||
core.removeAuthInfo(newlyCreatedAuthInfo)
|
||||
core.removeAccount(newlyCreatedAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
showPassword.value = false
|
||||
expandAdvancedSettings.value = false
|
||||
registrationInProgress.value = false
|
||||
|
||||
loginEnabled.addSource(username) {
|
||||
loginEnabled.value = isLoginButtonEnabled()
|
||||
}
|
||||
loginEnabled.addSource(domain) {
|
||||
loginEnabled.value = isLoginButtonEnabled()
|
||||
}
|
||||
|
||||
// TODO: handle formatting errors ?
|
||||
|
||||
availableTransports.add(TransportType.Udp.name.uppercase(Locale.getDefault()))
|
||||
availableTransports.add(TransportType.Tcp.name.uppercase(Locale.getDefault()))
|
||||
availableTransports.add(TransportType.Tls.name.uppercase(Locale.getDefault()))
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
domain.postValue(corePreferences.thirdPartySipAccountDefaultDomain)
|
||||
|
||||
val defaultTransport = corePreferences.thirdPartySipAccountDefaultTransport.uppercase(
|
||||
Locale.getDefault()
|
||||
)
|
||||
val index = if (defaultTransport.isNotEmpty()) {
|
||||
availableTransports.indexOf(defaultTransport)
|
||||
} else {
|
||||
availableTransports.size - 1
|
||||
}
|
||||
defaultTransportIndexEvent.postValue(Event(index))
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun login() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.loadConfigFromXml(corePreferences.thirdPartyDefaultValuesPath)
|
||||
|
||||
// Remove sip: in front of domain, just in case...
|
||||
val domainValue = domain.value.orEmpty().trim()
|
||||
val domainWithoutSip = if (domainValue.startsWith("sip:")) {
|
||||
domainValue.substring("sip:".length)
|
||||
} else {
|
||||
domainValue
|
||||
}
|
||||
val domainAddress = Factory.instance().createAddress("sip:$domainWithoutSip")
|
||||
val port = domainAddress?.port ?: -1
|
||||
if (port != -1) {
|
||||
Log.w("$TAG It seems a port [$port] was set in the domain [$domainValue], removing it from SIP identity but setting it to proxy server URI")
|
||||
}
|
||||
val domain = domainAddress?.domain ?: domainWithoutSip
|
||||
|
||||
// Allow to enter SIP identity instead of simply username
|
||||
// in case identity domain doesn't match proxy domain
|
||||
var user = username.value.orEmpty().trim()
|
||||
if (user.startsWith("sip:")) {
|
||||
user = user.substring("sip:".length)
|
||||
} else if (user.startsWith("sips:")) {
|
||||
user = user.substring("sips:".length)
|
||||
}
|
||||
if (user.contains("@")) {
|
||||
user = user.split("@")[0]
|
||||
}
|
||||
|
||||
val userId = authId.value.orEmpty().trim()
|
||||
|
||||
Log.i("$TAG Parsed username is [$user], user ID [$userId] and domain [$domain]")
|
||||
val identity = "sip:$user@$domain"
|
||||
val identityAddress = Factory.instance().createAddress(identity)
|
||||
if (identityAddress == null) {
|
||||
Log.e("$TAG Can't parse [$identity] as Address!")
|
||||
showRedToast(R.string.assistant_login_cant_parse_address_toast, R.drawable.warning_circle)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
Log.i("$TAG Computed SIP identity is [${identityAddress.asStringUriOnly()}]")
|
||||
|
||||
val accounts = core.accountList
|
||||
val found = accounts.find {
|
||||
it.params.identityAddress?.weakEqual(identityAddress) == true
|
||||
}
|
||||
if (found != null) {
|
||||
Log.w("$TAG An account with the same identity address [${found.params.identityAddress?.asStringUriOnly()}] already exists, do not add it again!")
|
||||
showRedToast(R.string.assistant_account_login_already_connected_error, R.drawable.warning_circle)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
newlyCreatedAuthInfo = Factory.instance().createAuthInfo(
|
||||
user,
|
||||
userId,
|
||||
password.value.orEmpty().trim(),
|
||||
null,
|
||||
null,
|
||||
domainAddress?.domain ?: domainValue
|
||||
)
|
||||
core.addAuthInfo(newlyCreatedAuthInfo)
|
||||
|
||||
val accountParams = core.createAccountParams()
|
||||
|
||||
if (displayName.value.orEmpty().isNotEmpty()) {
|
||||
identityAddress.displayName = displayName.value.orEmpty().trim()
|
||||
}
|
||||
accountParams.identityAddress = identityAddress
|
||||
|
||||
val proxyServerValue = proxy.value.orEmpty().trim()
|
||||
val proxyServerAddress = if (proxyServerValue.isNotEmpty()) {
|
||||
val server = if (proxyServerValue.startsWith("sip:")) {
|
||||
proxyServerValue
|
||||
} else {
|
||||
"sip:$proxyServerValue"
|
||||
}
|
||||
Factory.instance().createAddress(server)
|
||||
} else {
|
||||
domainAddress ?: Factory.instance().createAddress("sip:$domainWithoutSip")
|
||||
}
|
||||
proxyServerAddress?.transport = when (transport.value.orEmpty().trim()) {
|
||||
TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp
|
||||
TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls
|
||||
else -> TransportType.Udp
|
||||
}
|
||||
Log.i("$TAG Created proxy server SIP address [${proxyServerAddress?.asStringUriOnly()}]")
|
||||
accountParams.serverAddress = proxyServerAddress
|
||||
|
||||
val outboundProxyValue = outboundProxy.value.orEmpty().trim()
|
||||
val outboundProxyAddress = if (outboundProxyValue.isNotEmpty()) {
|
||||
val server = if (outboundProxyValue.startsWith("sip:")) {
|
||||
outboundProxyValue
|
||||
} else {
|
||||
"sip:$outboundProxyValue"
|
||||
}
|
||||
Factory.instance().createAddress(server)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (outboundProxyAddress != null) {
|
||||
outboundProxyAddress.transport = when (transport.value.orEmpty().trim()) {
|
||||
TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp
|
||||
TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls
|
||||
else -> TransportType.Udp
|
||||
}
|
||||
Log.i("$TAG Created outbound proxy server SIP address [${outboundProxyAddress?.asStringUriOnly()}]")
|
||||
accountParams.setRoutesAddresses(arrayOf(outboundProxyAddress))
|
||||
}
|
||||
|
||||
val prefix = internationalPrefix.value.orEmpty().trim()
|
||||
val isoCountryCode = internationalPrefixIsoCountryCode.value.orEmpty()
|
||||
if (prefix.isNotEmpty()) {
|
||||
val prefixDigits = if (prefix.startsWith("+")) {
|
||||
prefix.substring(1)
|
||||
} else {
|
||||
prefix
|
||||
}
|
||||
if (prefixDigits.isNotEmpty()) {
|
||||
Log.i(
|
||||
"$TAG Setting international prefix [$prefixDigits]($isoCountryCode) in account params"
|
||||
)
|
||||
accountParams.internationalPrefix = prefixDigits
|
||||
accountParams.internationalPrefixIsoCountryCode = isoCountryCode
|
||||
}
|
||||
}
|
||||
|
||||
newlyCreatedAccount = core.createAccount(accountParams)
|
||||
|
||||
registrationInProgress.postValue(true)
|
||||
core.addListener(coreListener)
|
||||
core.addAccount(newlyCreatedAccount)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun toggleShowPassword() {
|
||||
showPassword.value = showPassword.value == false
|
||||
}
|
||||
|
||||
@UiThread
|
||||
private fun isLoginButtonEnabled(): Boolean {
|
||||
// Password isn't mandatory as authentication could be Bearer
|
||||
return username.value.orEmpty().isNotEmpty() && domain.value.orEmpty().isNotEmpty()
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun toggleAdvancedSettingsExpand() {
|
||||
expandAdvancedSettings.value = expandAdvancedSettings.value == false
|
||||
}
|
||||
}
|
||||
584
app/src/main/java/org/linphone/ui/call/CallActivity.kt
Normal file
584
app/src/main/java/org/linphone/ui/call/CallActivity.kt
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.KeyboardShortcutGroup
|
||||
import android.view.KeyboardShortcutInfo
|
||||
import android.view.Menu
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import androidx.window.layout.WindowInfoTracker
|
||||
import androidx.window.layout.WindowLayoutInfo
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlin.math.max
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.compatibility.Api28Compatibility
|
||||
import org.linphone.compatibility.Compatibility
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallActivityBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.call.conference.fragment.ActiveConferenceCallFragmentDirections
|
||||
import org.linphone.ui.call.conference.fragment.ConferenceLayoutMenuDialogFragment
|
||||
import org.linphone.ui.call.fragment.ActiveCallFragmentDirections
|
||||
import org.linphone.ui.call.fragment.AudioDevicesMenuDialogFragment
|
||||
import org.linphone.ui.call.fragment.CallsListFragmentDirections
|
||||
import org.linphone.ui.call.fragment.IncomingCallFragmentDirections
|
||||
import org.linphone.ui.call.fragment.OutgoingCallFragmentDirections
|
||||
import org.linphone.ui.call.model.AudioDeviceModel
|
||||
import org.linphone.ui.call.viewmodel.CallsViewModel
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.ui.call.viewmodel.SharedCallViewModel
|
||||
import org.linphone.ui.main.MainActivity
|
||||
import org.linphone.utils.AppUtils
|
||||
|
||||
@UiThread
|
||||
class CallActivity : GenericActivity() {
|
||||
companion object {
|
||||
private const val TAG = "[Call Activity]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallActivityBinding
|
||||
|
||||
private lateinit var sharedViewModel: SharedCallViewModel
|
||||
private lateinit var callsViewModel: CallsViewModel
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
private var bottomSheetDialog: BottomSheetDialogFragment? = null
|
||||
|
||||
private var isPipSupported = false
|
||||
|
||||
private val requestCameraPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
Log.i("$TAG CAMERA permission has been granted, enabling video")
|
||||
callViewModel.toggleVideo()
|
||||
} else {
|
||||
Log.e("$TAG CAMERA permission has been denied")
|
||||
}
|
||||
}
|
||||
|
||||
private val requestRecordAudioPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
Log.i("$TAG RECORD_AUDIO permission has been granted, un-muting microphone")
|
||||
callViewModel.toggleMuteMicrophone()
|
||||
} else {
|
||||
Log.e("$TAG RECORD_AUDIO permission has been denied")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTheme(): Resources.Theme {
|
||||
val mainColor = corePreferences.themeMainColor
|
||||
val theme = super.getTheme()
|
||||
when (mainColor) {
|
||||
"yellow" -> theme.applyStyle(R.style.Theme_LinphoneInCallYellow, true)
|
||||
"green" -> theme.applyStyle(R.style.Theme_LinphoneInCallGreen, true)
|
||||
"blue" -> theme.applyStyle(R.style.Theme_LinphoneInCallBlue, true)
|
||||
"red" -> theme.applyStyle(R.style.Theme_LinphoneInCallRed, true)
|
||||
"pink" -> theme.applyStyle(R.style.Theme_LinphoneInCallPink, true)
|
||||
"purple" -> theme.applyStyle(R.style.Theme_LinphoneInCallPurple, true)
|
||||
else -> theme.applyStyle(R.style.Theme_LinphoneInCall, true)
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val style = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) {
|
||||
true // Force dark mode
|
||||
}
|
||||
enableEdgeToEdge(style, style)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = DataBindingUtil.setContentView(this, R.layout.call_activity)
|
||||
binding.lifecycleOwner = this
|
||||
setUpToastsArea(binding.toastsArea)
|
||||
|
||||
val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.otherCallsTopBar.root) { v, windowInsets ->
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.updatePadding(0, insets.top, 0, 0)
|
||||
windowInsets
|
||||
}
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.callNavContainer) { v, windowInsets ->
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val keyboard = windowInsets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
v.updatePadding(insets.left, 0, insets.right, max(insets.bottom, keyboard.bottom))
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
WindowInfoTracker
|
||||
.getOrCreate(this@CallActivity)
|
||||
.windowLayoutInfo(this@CallActivity)
|
||||
.collect { newLayoutInfo ->
|
||||
updateCurrentLayout(newLayoutInfo)
|
||||
}
|
||||
}
|
||||
|
||||
isPipSupported = packageManager.hasSystemFeature(
|
||||
PackageManager.FEATURE_PICTURE_IN_PICTURE
|
||||
)
|
||||
Log.i("$TAG Is PiP supported [$isPipSupported]")
|
||||
|
||||
sharedViewModel = run {
|
||||
ViewModelProvider(this)[SharedCallViewModel::class.java]
|
||||
}
|
||||
|
||||
callViewModel = run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
binding.callViewModel = callViewModel
|
||||
|
||||
callsViewModel = run {
|
||||
ViewModelProvider(this)[CallsViewModel::class.java]
|
||||
}
|
||||
binding.callsViewModel = callsViewModel
|
||||
|
||||
callViewModel.showAudioDevicesListEvent.observe(this) {
|
||||
it.consume { devices ->
|
||||
showAudioRoutesMenu(devices)
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.conferenceModel.showLayoutMenuEvent.observe(this) {
|
||||
it.consume {
|
||||
showConferenceLayoutMenu()
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.isVideoEnabled.observe(this) { enabled ->
|
||||
if (isPipSupported) {
|
||||
// Only enable PiP if video is enabled
|
||||
Compatibility.enableAutoEnterPiP(this, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.transferInProgressEvent.observe(this) {
|
||||
it.consume {
|
||||
showGreenToast(
|
||||
getString(R.string.call_transfer_in_progress_toast),
|
||||
R.drawable.phone_transfer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.transferFailedEvent.observe(this) {
|
||||
it.consume {
|
||||
showRedToast(
|
||||
getString(R.string.call_transfer_failed_toast),
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.goToEndedCallEvent.observe(this) {
|
||||
it.consume { message ->
|
||||
if (message.isNotEmpty()) {
|
||||
showRedToast(message, R.drawable.warning_circle)
|
||||
}
|
||||
|
||||
val action = ActiveCallFragmentDirections.actionGlobalEndedCallFragment()
|
||||
findNavController(R.id.call_nav_container).navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.finishActivityEvent.observe(this) {
|
||||
it.consume {
|
||||
Log.i("$TAG Finishing activity")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.requestRecordAudioPermission.observe(this) {
|
||||
it.consume {
|
||||
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.RECORD_AUDIO)) {
|
||||
Log.w("$TAG Asking for RECORD_AUDIO permission")
|
||||
requestRecordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
} else {
|
||||
Log.i("$TAG Permission request for RECORD_AUDIO will be automatically denied, go to android app settings instead")
|
||||
goToAndroidPermissionSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.requestCameraPermission.observe(this) {
|
||||
it.consume {
|
||||
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
|
||||
Log.w("$TAG Asking for CAMERA permission")
|
||||
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
} else {
|
||||
Log.i("$TAG Permission request for CAMERA will be automatically denied, go to android app settings instead")
|
||||
goToAndroidPermissionSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.proximitySensorEnabled.observe(this) { enabled ->
|
||||
Log.i("$TAG ${if (enabled) "Enabling" else "Disabling"} proximity sensor")
|
||||
coreContext.enableProximitySensor(enabled)
|
||||
}
|
||||
|
||||
callsViewModel.showIncomingCallEvent.observe(this) {
|
||||
it.consume {
|
||||
val action = IncomingCallFragmentDirections.actionGlobalIncomingCallFragment()
|
||||
findNavController(R.id.call_nav_container).navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
callsViewModel.showOutgoingCallEvent.observe(this) {
|
||||
it.consume {
|
||||
val action = OutgoingCallFragmentDirections.actionGlobalOutgoingCallFragment()
|
||||
findNavController(R.id.call_nav_container).navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
callsViewModel.goToActiveCallEvent.observe(this) {
|
||||
it.consume { singleCall ->
|
||||
navigateToActiveCall(singleCall)
|
||||
}
|
||||
}
|
||||
|
||||
callsViewModel.noCallFoundEvent.observe(this) {
|
||||
it.consume {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
callsViewModel.goToCallsListEvent.observe(this) {
|
||||
it.consume {
|
||||
val navController = findNavController(R.id.call_nav_container)
|
||||
if (navController.currentDestination?.id == R.id.activeCallFragment) {
|
||||
val action =
|
||||
ActiveCallFragmentDirections.actionActiveCallFragmentToCallsListFragment()
|
||||
navController.navigate(action)
|
||||
} else if (navController.currentDestination?.id == R.id.activeConferenceCallFragment) {
|
||||
val action =
|
||||
ActiveConferenceCallFragmentDirections.actionActiveConferenceCallFragmentToCallsListFragment()
|
||||
navController.navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.toggleFullScreenEvent.observe(this) {
|
||||
it.consume { hide ->
|
||||
hideUI(hide)
|
||||
}
|
||||
}
|
||||
|
||||
coreContext.refreshMicrophoneMuteStateEvent.observe(this) {
|
||||
it.consume {
|
||||
Log.i(
|
||||
"$TAG Refreshing microphone mute state, probably to sync with Android Auto action"
|
||||
)
|
||||
callViewModel.refreshMicrophoneState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
findNavController(R.id.call_nav_container).addOnDestinationChangedListener { _, destination, _ ->
|
||||
val showTopBar = when (destination.id) {
|
||||
R.id.inCallConversationFragment, R.id.transferCallFragment, R.id.newCallFragment -> true
|
||||
else -> false
|
||||
}
|
||||
callsViewModel.showTopBar.postValue(showTopBar)
|
||||
}
|
||||
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.e("$TAG RECORD_AUDIO permission isn't granted")
|
||||
val message = R.string.call_audio_record_permission_not_granted_toast
|
||||
val icon = R.drawable.warning_circle
|
||||
showRedToast(getString(message), icon)
|
||||
}
|
||||
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.CAMERA
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.e("$TAG CAMERA permission isn't granted")
|
||||
val message = R.string.call_camera_permission_not_granted_toast
|
||||
val icon = R.drawable.warning_circle
|
||||
showRedToast(getString(message), icon)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
val isInPipMode = isInPictureInPictureMode
|
||||
Log.i("$TAG onResume: is in PiP mode? [$isInPipMode]")
|
||||
if (::callViewModel.isInitialized) {
|
||||
callViewModel.pipMode.value = isInPipMode
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
coreContext.enableProximitySensor(false)
|
||||
|
||||
super.onPause()
|
||||
|
||||
bottomSheetDialog?.dismiss()
|
||||
bottomSheetDialog = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
coreContext.enableProximitySensor(false)
|
||||
|
||||
super.onDestroy()
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Clearing native video window ID")
|
||||
core.nativeVideoWindowId = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
|
||||
if (intent.extras?.getBoolean("ActiveCall", false) == true) {
|
||||
navigateToActiveCall(
|
||||
callViewModel.conferenceModel.isCurrentCallInConference.value == false
|
||||
)
|
||||
} else if (intent.extras?.getBoolean("IncomingCall", false) == true) {
|
||||
val action = IncomingCallFragmentDirections.actionGlobalIncomingCallFragment()
|
||||
findNavController(R.id.call_nav_container).navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
|
||||
if (::callViewModel.isInitialized) {
|
||||
if (isPipSupported && callViewModel.isVideoEnabled.value == true) {
|
||||
Log.i("$TAG User leave hint, try entering PiP mode")
|
||||
val pipMode = Compatibility.enterPipMode(this)
|
||||
if (!pipMode) {
|
||||
Log.e("$TAG Failed to enter PiP mode")
|
||||
callViewModel.pipMode.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProvideKeyboardShortcuts(
|
||||
data: MutableList<KeyboardShortcutGroup?>?,
|
||||
menu: Menu?,
|
||||
deviceId: Int
|
||||
) {
|
||||
super.onProvideKeyboardShortcuts(data, menu, deviceId)
|
||||
|
||||
val keyboardShortcutGroup = KeyboardShortcutGroup(
|
||||
"Answer/Decline incoming call",
|
||||
listOf(
|
||||
KeyboardShortcutInfo(
|
||||
AppUtils.getString(R.string.call_action_answer),
|
||||
KeyEvent.KEYCODE_A,
|
||||
KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON
|
||||
),
|
||||
KeyboardShortcutInfo(
|
||||
AppUtils.getString(R.string.call_action_decline),
|
||||
KeyEvent.KEYCODE_D,
|
||||
KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON
|
||||
)
|
||||
)
|
||||
)
|
||||
data?.add(keyboardShortcutGroup)
|
||||
Log.i("$TAG Incoming call answer/decline shortcuts added")
|
||||
}
|
||||
|
||||
override fun onKeyShortcut(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (event?.isCtrlPressed == true && event.isShiftPressed) {
|
||||
val navController = findNavController(R.id.call_nav_container)
|
||||
if (navController.currentDestination?.id == R.id.incomingCallFragment) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_A -> {
|
||||
Log.i("$TAG Answer incoming call shortcut triggered")
|
||||
callViewModel.answer()
|
||||
}
|
||||
KeyEvent.KEYCODE_D -> {
|
||||
Log.i("$TAG Decline incoming call shortcut triggered")
|
||||
callViewModel.hangUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun goToMainActivity() {
|
||||
if (isPipSupported && callViewModel.isVideoEnabled.value == true) {
|
||||
Log.i("$TAG User is going back to MainActivity, try entering PiP mode")
|
||||
val pipMode = Api28Compatibility.enterPipMode(this)
|
||||
if (!pipMode) {
|
||||
Log.e("$TAG Failed to enter PiP mode, finishing Activity")
|
||||
callViewModel.pipMode.value = false
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
Log.i("$TAG Launching MainActivity to have PiP above it")
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
startActivity(intent)
|
||||
} else {
|
||||
Log.i("$TAG Either PiP isn't supported or video is not enabled, finishing Activity")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCurrentLayout(newLayoutInfo: WindowLayoutInfo) {
|
||||
if (newLayoutInfo.displayFeatures.isNotEmpty()) {
|
||||
for (feature in newLayoutInfo.displayFeatures) {
|
||||
val foldingFeature = feature as? FoldingFeature
|
||||
if (foldingFeature != null) {
|
||||
Log.i(
|
||||
"$TAG Folding feature state changed: ${foldingFeature.state}, orientation is ${foldingFeature.orientation}"
|
||||
)
|
||||
sharedViewModel.foldingState.value = foldingFeature
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToActiveCall(notInConference: Boolean) {
|
||||
val navController = findNavController(R.id.call_nav_container)
|
||||
val action = when (navController.currentDestination?.id) {
|
||||
R.id.outgoingCallFragment -> {
|
||||
if (notInConference) {
|
||||
Log.i("$TAG Going from outgoing call fragment to call fragment")
|
||||
OutgoingCallFragmentDirections.actionOutgoingCallFragmentToActiveCallFragment()
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG Going from outgoing call fragment to conference call fragment"
|
||||
)
|
||||
OutgoingCallFragmentDirections.actionOutgoingCallFragmentToActiveConferenceCallFragment()
|
||||
}
|
||||
}
|
||||
R.id.incomingCallFragment -> {
|
||||
if (notInConference) {
|
||||
Log.i("$TAG Going from incoming call fragment to call fragment")
|
||||
IncomingCallFragmentDirections.actionIncomingCallFragmentToActiveCallFragment()
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG Going from incoming call fragment to conference call fragment"
|
||||
)
|
||||
IncomingCallFragmentDirections.actionIncomingCallFragmentToActiveConferenceCallFragment()
|
||||
}
|
||||
}
|
||||
R.id.activeCallFragment -> {
|
||||
if (notInConference) {
|
||||
Log.i("$TAG Going from call fragment to call fragment")
|
||||
ActiveCallFragmentDirections.actionGlobalActiveCallFragment()
|
||||
} else {
|
||||
Log.i("$TAG Going from call fragment to conference call fragment")
|
||||
ActiveCallFragmentDirections.actionActiveCallFragmentToActiveConferenceCallFragment()
|
||||
}
|
||||
}
|
||||
R.id.activeConferenceCallFragment -> {
|
||||
if (notInConference) {
|
||||
Log.i("$TAG Going from conference call fragment to call fragment")
|
||||
ActiveConferenceCallFragmentDirections.actionActiveConferenceCallFragmentToActiveCallFragment()
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG Going from conference call fragment to conference call fragment"
|
||||
)
|
||||
ActiveConferenceCallFragmentDirections.actionGlobalActiveConferenceCallFragment()
|
||||
}
|
||||
}
|
||||
R.id.callsListFragment -> {
|
||||
if (notInConference) {
|
||||
Log.i("$TAG Going calls list fragment to active call fragment")
|
||||
CallsListFragmentDirections.actionCallsListFragmentToActiveCallFragment()
|
||||
} else {
|
||||
Log.i("$TAG Going calls list fragment to conference fragment")
|
||||
CallsListFragmentDirections.actionCallsListFragmentToActiveConferenceCallFragment()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (notInConference) {
|
||||
Log.i("$TAG Going from call fragment to call fragment")
|
||||
ActiveCallFragmentDirections.actionGlobalActiveCallFragment()
|
||||
} else {
|
||||
Log.i("$TAG Going from call fragment to conference call fragment")
|
||||
ActiveConferenceCallFragmentDirections.actionGlobalActiveConferenceCallFragment()
|
||||
}
|
||||
}
|
||||
}
|
||||
navController.navigate(action)
|
||||
}
|
||||
|
||||
private fun hideUI(hide: Boolean) {
|
||||
Log.i("$TAG Switching full screen mode to [${if (hide) "ON" else "OFF"}]")
|
||||
val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
if (hide) {
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
} else {
|
||||
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAudioRoutesMenu(devicesList: List<AudioDeviceModel>) {
|
||||
val modalBottomSheet = AudioDevicesMenuDialogFragment(devicesList)
|
||||
modalBottomSheet.show(supportFragmentManager, AudioDevicesMenuDialogFragment.TAG)
|
||||
bottomSheetDialog = modalBottomSheet
|
||||
}
|
||||
|
||||
private fun showConferenceLayoutMenu() {
|
||||
val modalBottomSheet = ConferenceLayoutMenuDialogFragment(callViewModel.conferenceModel)
|
||||
modalBottomSheet.show(supportFragmentManager, ConferenceLayoutMenuDialogFragment.TAG)
|
||||
bottomSheetDialog = modalBottomSheet
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.linphone.R
|
||||
import org.linphone.databinding.CallListCellBinding
|
||||
import org.linphone.ui.call.model.CallModel
|
||||
import org.linphone.utils.Event
|
||||
|
||||
class CallsListAdapter :
|
||||
ListAdapter<CallModel, RecyclerView.ViewHolder>(CallDiffCallback()) {
|
||||
var selectedAdapterPosition = -1
|
||||
|
||||
val callClickedEvent: MutableLiveData<Event<CallModel>> by lazy {
|
||||
MutableLiveData<Event<CallModel>>()
|
||||
}
|
||||
|
||||
val callLongClickedEvent: MutableLiveData<Event<CallModel>> by lazy {
|
||||
MutableLiveData<Event<CallModel>>()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val binding: CallListCellBinding = DataBindingUtil.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
R.layout.call_list_cell,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val viewHolder = ViewHolder(binding)
|
||||
binding.apply {
|
||||
lifecycleOwner = parent.findViewTreeLifecycleOwner()
|
||||
|
||||
setOnClickListener {
|
||||
callClickedEvent.value = Event(model!!)
|
||||
}
|
||||
|
||||
setOnLongClickListener {
|
||||
selectedAdapterPosition = viewHolder.bindingAdapterPosition
|
||||
root.isSelected = true
|
||||
callLongClickedEvent.value = Event(model!!)
|
||||
true
|
||||
}
|
||||
}
|
||||
return viewHolder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
(holder as ViewHolder).bind(getItem(position))
|
||||
}
|
||||
|
||||
fun resetSelection() {
|
||||
notifyItemChanged(selectedAdapterPosition)
|
||||
selectedAdapterPosition = -1
|
||||
}
|
||||
|
||||
inner class ViewHolder(
|
||||
val binding: CallListCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
@UiThread
|
||||
fun bind(callModel: CallModel) {
|
||||
with(binding) {
|
||||
model = callModel
|
||||
|
||||
binding.root.isSelected = bindingAdapterPosition == selectedAdapterPosition
|
||||
|
||||
executePendingBindings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CallDiffCallback : DiffUtil.ItemCallback<CallModel>() {
|
||||
override fun areItemsTheSame(oldItem: CallModel, newItem: CallModel): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: CallModel, newItem: CallModel): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,34 +17,31 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.conversations
|
||||
package org.linphone.ui.call.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.linphone.R
|
||||
import org.linphone.databinding.ChatRoomListCellBinding
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.databinding.CallConferenceParticipantListCellBinding
|
||||
import org.linphone.ui.call.conference.model.ConferenceParticipantModel
|
||||
|
||||
class ConversationsListAdapter(
|
||||
private val viewLifecycleOwner: LifecycleOwner
|
||||
) : ListAdapter<ChatRoomData, RecyclerView.ViewHolder>(ConversationDiffCallback()) {
|
||||
val chatRoomClickedEvent = MutableLiveData<Event<ChatRoomData>>()
|
||||
|
||||
val chatRoomMenuClickedEvent = MutableLiveData<Event<ChatRoomData>>()
|
||||
class ConferenceParticipantsListAdapter :
|
||||
ListAdapter<ConferenceParticipantModel, RecyclerView.ViewHolder>(ParticipantDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val binding: ChatRoomListCellBinding = DataBindingUtil.inflate(
|
||||
val binding: CallConferenceParticipantListCellBinding = DataBindingUtil.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
R.layout.chat_room_list_cell,
|
||||
R.layout.call_conference_participant_list_cell,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
binding.lifecycleOwner = parent.findViewTreeLifecycleOwner()
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
|
|
@ -52,32 +49,32 @@ class ConversationsListAdapter(
|
|||
(holder as ViewHolder).bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class ViewHolder(
|
||||
val binding: ChatRoomListCellBinding
|
||||
class ViewHolder(
|
||||
val binding: CallConferenceParticipantListCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(chatRoomData: ChatRoomData) {
|
||||
@UiThread
|
||||
fun bind(participantModel: ConferenceParticipantModel) {
|
||||
with(binding) {
|
||||
data = chatRoomData
|
||||
model = participantModel
|
||||
|
||||
lifecycleOwner = viewLifecycleOwner
|
||||
executePendingBindings()
|
||||
|
||||
chatRoomData.chatRoomDataListener = object : ChatRoomDataListener() {
|
||||
override fun onClicked() {
|
||||
chatRoomClickedEvent.value = Event(chatRoomData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ConversationDiffCallback : DiffUtil.ItemCallback<ChatRoomData>() {
|
||||
override fun areItemsTheSame(oldItem: ChatRoomData, newItem: ChatRoomData): Boolean {
|
||||
return oldItem.id.compareTo(newItem.id) == 0
|
||||
}
|
||||
private class ParticipantDiffCallback : DiffUtil.ItemCallback<ConferenceParticipantModel>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: ConferenceParticipantModel,
|
||||
newItem: ConferenceParticipantModel
|
||||
): Boolean {
|
||||
return oldItem.sipUri == newItem.sipUri
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ChatRoomData, newItem: ChatRoomData): Boolean {
|
||||
return false
|
||||
override fun areContentsTheSame(
|
||||
oldItem: ConferenceParticipantModel,
|
||||
newItem: ConferenceParticipantModel
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallActiveConferenceFragmentBinding
|
||||
import org.linphone.ui.call.CallActivity
|
||||
import org.linphone.ui.call.fragment.GenericCallFragment
|
||||
import org.linphone.ui.call.viewmodel.CallsViewModel
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.startAnimatedDrawable
|
||||
|
||||
class ActiveConferenceCallFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Active Conference Call Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallActiveConferenceFragmentBinding
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
private lateinit var callsViewModel: CallsViewModel
|
||||
|
||||
private val actionsBottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
val drawable = AnimatedVectorDrawableCompat.create(
|
||||
requireContext(),
|
||||
R.drawable.animated_handle_to_caret
|
||||
)
|
||||
binding.bottomBar.mainActions.callActionsHandle.setImageDrawable(drawable)
|
||||
} else if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
val drawable = AnimatedVectorDrawableCompat.create(
|
||||
requireContext(),
|
||||
R.drawable.animated_caret_to_handle
|
||||
)
|
||||
binding.bottomBar.mainActions.callActionsHandle.setImageDrawable(drawable)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
|
||||
}
|
||||
|
||||
private val backPressedCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
val actionsBottomSheetBehavior = BottomSheetBehavior.from(binding.bottomBar.root)
|
||||
if (actionsBottomSheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
return
|
||||
}
|
||||
|
||||
val callStatsBottomSheetBehavior = BottomSheetBehavior.from(binding.callStats.root)
|
||||
if (callStatsBottomSheetBehavior.state != BottomSheetBehavior.STATE_HIDDEN) {
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
val callMediaEncryptionStatsBottomSheetBehavior = BottomSheetBehavior.from(
|
||||
binding.callMediaEncryptionStats.root
|
||||
)
|
||||
if (callMediaEncryptionStatsBottomSheetBehavior.state != BottomSheetBehavior.STATE_HIDDEN) {
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
Log.i("$TAG Back gesture/click detected, no bottom sheet is expanded, going back")
|
||||
isEnabled = false
|
||||
try {
|
||||
Log.i("$TAG Back gesture detected, going to MainActivity")
|
||||
(requireActivity() as CallActivity).goToMainActivity()
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.w("$TAG Can't go back: $ise")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallActiveConferenceFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callViewModel)
|
||||
observeToastEvents(callViewModel.conferenceModel)
|
||||
|
||||
callsViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CallsViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callsViewModel)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = callViewModel
|
||||
binding.conferenceViewModel = callViewModel.conferenceModel
|
||||
binding.callsViewModel = callsViewModel
|
||||
binding.numpadModel = callViewModel.numpadModel
|
||||
|
||||
sharedViewModel.foldingState.observe(viewLifecycleOwner) { feature ->
|
||||
updateHingeRelatedConstraints(feature)
|
||||
}
|
||||
|
||||
val actionsBottomSheetBehavior = BottomSheetBehavior.from(binding.bottomBar.root)
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
actionsBottomSheetBehavior.addBottomSheetCallback(actionsBottomSheetCallback)
|
||||
|
||||
val callStatsBottomSheetBehavior = BottomSheetBehavior.from(binding.callStats.root)
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callStatsBottomSheetBehavior.skipCollapsed = true
|
||||
|
||||
val callMediaEncryptionStatsBottomSheetBehavior = BottomSheetBehavior.from(
|
||||
binding.callMediaEncryptionStats.root
|
||||
)
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callMediaEncryptionStatsBottomSheetBehavior.skipCollapsed = true
|
||||
|
||||
callViewModel.callDuration.observe(viewLifecycleOwner) { duration ->
|
||||
binding.chronometer.base = SystemClock.elapsedRealtime() - (1000 * duration)
|
||||
binding.chronometer.start()
|
||||
}
|
||||
|
||||
callViewModel.toggleExtraActionsBottomSheetEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val state = actionsBottomSheetBehavior.state
|
||||
if (state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
val drawable = AnimatedVectorDrawableCompat.create(
|
||||
requireContext(),
|
||||
R.drawable.animated_caret_to_handle
|
||||
)
|
||||
binding.bottomBar.mainActions.callActionsHandle.setImageDrawable(drawable)
|
||||
binding.bottomBar.mainActions.callActionsHandle.startAnimatedDrawable()
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (state == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
val drawable = AnimatedVectorDrawableCompat.create(
|
||||
requireContext(),
|
||||
R.drawable.animated_handle_to_caret
|
||||
)
|
||||
binding.bottomBar.mainActions.callActionsHandle.setImageDrawable(drawable)
|
||||
binding.bottomBar.mainActions.callActionsHandle.startAnimatedDrawable()
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.conferenceModel.fullScreenMode.observe(viewLifecycleOwner) { hide ->
|
||||
Log.i("$TAG Switching full screen mode to [${if (hide) "ON" else "OFF"}]")
|
||||
sharedViewModel.toggleFullScreenEvent.value = Event(hide)
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
|
||||
if (hide != callViewModel.fullScreenMode.value) {
|
||||
callViewModel.fullScreenMode.value = hide
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.conferenceModel.conferenceLayout.observe(viewLifecycleOwner) { layout ->
|
||||
// Collapse bottom sheet after changing conference layout
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
callViewModel.conferenceModel.participants.observe(viewLifecycleOwner) { participants ->
|
||||
coreContext.postOnCoreThread { core ->
|
||||
if (participants.size == 1) {
|
||||
Log.i("$TAG We are alone in that conference, using nativePreviewWindowId")
|
||||
core.nativePreviewWindowId = binding.localPreviewVideoSurface
|
||||
|
||||
if (callViewModel.conferenceModel.fullScreenMode.value == true && callViewModel.conferenceModel.isMeParticipantSendingVideo.value == false) {
|
||||
// Don't forget to leave full screen mode, otherwise we won't be able to leave it by touching video surface...
|
||||
callViewModel.conferenceModel.fullScreenMode.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.conferenceModel.firstParticipantOtherThanOurselvesJoinedEvent.observe(
|
||||
viewLifecycleOwner
|
||||
) {
|
||||
it.consume {
|
||||
if (callViewModel.conferenceModel.fullScreenMode.value == false) {
|
||||
Log.i("$TAG First participant joined conference, switching to full screen mode")
|
||||
callViewModel.conferenceModel.toggleFullScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.conferenceModel.goToConversationEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { conversationId ->
|
||||
if (findNavController().currentDestination?.id == R.id.activeConferenceCallFragment) {
|
||||
Log.i("$TAG Display conversation with conversation ID [$conversationId]")
|
||||
val action =
|
||||
ActiveConferenceCallFragmentDirections.actionActiveConferenceCallFragmentToInCallConversationFragment(
|
||||
conversationId
|
||||
)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.goToCallEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
if (findNavController().currentDestination?.id == R.id.activeConferenceCallFragment) {
|
||||
Log.i("$TAG Going to active call fragment")
|
||||
val action =
|
||||
ActiveConferenceCallFragmentDirections.actionActiveConferenceCallFragmentToActiveCallFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.setBackClickListener {
|
||||
(requireActivity() as CallActivity).goToMainActivity()
|
||||
}
|
||||
|
||||
binding.setCallsListClickListener {
|
||||
Log.i("$TAG Going to calls list fragment")
|
||||
if (findNavController().currentDestination?.id == R.id.activeConferenceCallFragment) {
|
||||
val action =
|
||||
ActiveConferenceCallFragmentDirections.actionActiveConferenceCallFragmentToCallsListFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setParticipantsListClickListener {
|
||||
Log.i("$TAG Going to conference participants list fragment")
|
||||
if (findNavController().currentDestination?.id == R.id.activeConferenceCallFragment) {
|
||||
val action =
|
||||
ActiveConferenceCallFragmentDirections.actionActiveConferenceCallFragmentToConferenceParticipantsListFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setCopyConferenceUriToClipboardClickListener {
|
||||
val sipUri = callViewModel.conferenceModel.sipUri.value.orEmpty()
|
||||
if (sipUri.isNotEmpty()) {
|
||||
Log.i("$TAG Copying conference SIP URI [$sipUri] into clipboard")
|
||||
val label = "Conference SIP address"
|
||||
AppUtils.copyToClipboard(requireContext(), label, sipUri)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setCallStatisticsClickListener {
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
binding.setCallMediaEncryptionStatisticsClickListener {
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
backPressedCallback
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
(binding.root as? ViewGroup)?.doOnLayout {
|
||||
setupVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
// Need to be done manually
|
||||
callViewModel.updateCallDuration()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
cleanVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
|
||||
private fun updateHingeRelatedConstraints(feature: FoldingFeature) {
|
||||
Log.i("$TAG Updating constraint layout hinges: $feature")
|
||||
|
||||
val constraintLayout = binding.constraintLayout
|
||||
val set = ConstraintSet()
|
||||
set.clone(constraintLayout)
|
||||
|
||||
if (feature.isSeparating && feature.state == FoldingFeature.State.HALF_OPENED && feature.orientation == FoldingFeature.Orientation.HORIZONTAL) {
|
||||
set.setGuidelinePercent(R.id.hinge_top, 0.5f)
|
||||
set.setGuidelinePercent(R.id.hinge_bottom, 0.5f)
|
||||
callViewModel.halfOpenedFolded.value = true
|
||||
} else {
|
||||
set.setGuidelinePercent(R.id.hinge_top, 0f)
|
||||
set.setGuidelinePercent(R.id.hinge_bottom, 1f)
|
||||
callViewModel.halfOpenedFolded.value = false
|
||||
}
|
||||
|
||||
set.applyTo(constraintLayout)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallConferenceActiveSpeakerFragmentBinding
|
||||
import org.linphone.ui.call.conference.viewmodel.ConferenceViewModel
|
||||
import org.linphone.ui.call.fragment.GenericCallFragment
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
|
||||
@UiThread
|
||||
class ConferenceActiveSpeakerFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Conference Active Speaker Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallConferenceActiveSpeakerFragmentBinding
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallConferenceActiveSpeakerFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = callViewModel
|
||||
binding.conferenceViewModel = callViewModel.conferenceModel
|
||||
|
||||
callViewModel.conferenceModel.conferenceLayout.observe(viewLifecycleOwner) {
|
||||
when (it) {
|
||||
ConferenceViewModel.GRID_LAYOUT -> {
|
||||
Log.i(
|
||||
"$TAG Conference layout changed to mosaic, navigating to matching fragment"
|
||||
)
|
||||
if (findNavController().currentDestination?.id == R.id.conferenceActiveSpeakerFragment) {
|
||||
findNavController().navigate(
|
||||
R.id.action_conferenceActiveSpeakerFragment_to_conferenceGridFragment
|
||||
)
|
||||
}
|
||||
}
|
||||
ConferenceViewModel.AUDIO_ONLY_LAYOUT -> {
|
||||
Log.i(
|
||||
"$TAG Conference layout changed to audio only, navigating to matching fragment"
|
||||
)
|
||||
if (findNavController().currentDestination?.id == R.id.conferenceActiveSpeakerFragment) {
|
||||
findNavController().navigate(
|
||||
R.id.action_conferenceActiveSpeakerFragment_to_conferenceAudioOnlyFragment
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Setting native video window ID")
|
||||
core.nativeVideoWindowId = binding.activeSpeakerSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.GenericAddParticipantsFragmentBinding
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.ui.main.fragment.GenericAddressPickerFragment
|
||||
import org.linphone.ui.main.viewmodel.AddParticipantsViewModel
|
||||
|
||||
@UiThread
|
||||
class ConferenceAddParticipantsFragment : GenericAddressPickerFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Conference Add Participants Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: GenericAddParticipantsFragmentBinding
|
||||
|
||||
override lateinit var viewModel: AddParticipantsViewModel
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = GenericAddParticipantsFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun goBack(): Boolean {
|
||||
try {
|
||||
return findNavController().popBackStack()
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e("$TAG Can't go back popping back stack: $ise")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel = ViewModelProvider(this)[AddParticipantsViewModel::class.java]
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
|
||||
postponeEnterTransition()
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
setupRecyclerView(binding.contactsList)
|
||||
|
||||
viewModel.modelsList.observe(
|
||||
viewLifecycleOwner
|
||||
) {
|
||||
Log.i("$TAG Contacts & suggestions list is ready with [${it.size}] items")
|
||||
adapter.submitList(it)
|
||||
|
||||
attachAdapter()
|
||||
|
||||
(view.parent as? ViewGroup)?.doOnPreDraw {
|
||||
startPostponedEnterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.selectedSipUrisEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { list ->
|
||||
Log.i("$TAG Trying to add participant(s) [${list.size}] to conference")
|
||||
callViewModel.conferenceModel.inviteSipUrisIntoConference(list)
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallConferenceAudioOnlyFragmentBinding
|
||||
import org.linphone.ui.call.conference.viewmodel.ConferenceViewModel
|
||||
import org.linphone.ui.call.fragment.GenericCallFragment
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
|
||||
@UiThread
|
||||
class ConferenceAudioOnlyFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Conference Audio Only Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallConferenceAudioOnlyFragmentBinding
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallConferenceAudioOnlyFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = callViewModel
|
||||
binding.conferenceViewModel = callViewModel.conferenceModel
|
||||
|
||||
callViewModel.conferenceModel.conferenceLayout.observe(viewLifecycleOwner) {
|
||||
when (it) {
|
||||
ConferenceViewModel.ACTIVE_SPEAKER_LAYOUT -> {
|
||||
Log.i(
|
||||
"$TAG Conference layout changed to active speaker, navigating to matching fragment"
|
||||
)
|
||||
if (findNavController().currentDestination?.id == R.id.conferenceAudioOnlyFragment) {
|
||||
findNavController().navigate(
|
||||
R.id.action_conferenceAudioOnlyFragment_to_conferenceActiveSpeakerFragment
|
||||
)
|
||||
}
|
||||
}
|
||||
ConferenceViewModel.GRID_LAYOUT -> {
|
||||
Log.i(
|
||||
"$TAG Conference layout changed to mosaic, navigating to matching fragment"
|
||||
)
|
||||
if (findNavController().currentDestination?.id == R.id.conferenceAudioOnlyFragment) {
|
||||
findNavController().navigate(
|
||||
R.id.action_conferenceAudioOnlyFragment_to_conferenceGridFragment
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
Log.i("$TAG Making sure we are not in full-screen mode")
|
||||
callViewModel.fullScreenMode.value = false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallConferenceGridFragmentBinding
|
||||
import org.linphone.ui.call.conference.viewmodel.ConferenceViewModel
|
||||
import org.linphone.ui.call.fragment.GenericCallFragment
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
|
||||
@UiThread
|
||||
class ConferenceGridFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Conference Grid Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallConferenceGridFragmentBinding
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallConferenceGridFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = callViewModel
|
||||
binding.conferenceViewModel = callViewModel.conferenceModel
|
||||
|
||||
callViewModel.conferenceModel.conferenceLayout.observe(viewLifecycleOwner) {
|
||||
when (it) {
|
||||
ConferenceViewModel.ACTIVE_SPEAKER_LAYOUT -> {
|
||||
Log.i(
|
||||
"$TAG Conference layout changed to active speaker, navigating to matching fragment"
|
||||
)
|
||||
if (findNavController().currentDestination?.id == R.id.conferenceGridFragment) {
|
||||
findNavController().navigate(
|
||||
R.id.action_conferenceGridFragment_to_conferenceActiveSpeakerFragment
|
||||
)
|
||||
}
|
||||
}
|
||||
ConferenceViewModel.AUDIO_ONLY_LAYOUT -> {
|
||||
Log.i(
|
||||
"$TAG Conference layout changed to audio only, navigating to matching fragment"
|
||||
)
|
||||
if (findNavController().currentDestination?.id == R.id.conferenceGridFragment) {
|
||||
findNavController().navigate(
|
||||
R.id.action_conferenceGridFragment_to_conferenceAudioOnlyFragment
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.fragment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import org.linphone.databinding.CallConferenceLayoutBottomSheetBinding
|
||||
import org.linphone.ui.call.conference.viewmodel.ConferenceViewModel
|
||||
|
||||
@UiThread
|
||||
class ConferenceLayoutMenuDialogFragment(
|
||||
val conferenceModel: ConferenceViewModel,
|
||||
private val onDismiss: (() -> Unit)? = null
|
||||
) : BottomSheetDialogFragment() {
|
||||
companion object {
|
||||
const val TAG = "ConferenceLayoutMenuDialogFragment"
|
||||
}
|
||||
|
||||
override fun onCancel(dialog: DialogInterface) {
|
||||
onDismiss?.invoke()
|
||||
super.onCancel(dialog)
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
onDismiss?.invoke()
|
||||
super.onDismiss(dialog)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
||||
// Makes sure all menu entries are visible,
|
||||
// required for landscape mode (otherwise only first item is visible)
|
||||
dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = CallConferenceLayoutBottomSheetBinding.inflate(layoutInflater)
|
||||
view.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
view.viewModel = conferenceModel
|
||||
|
||||
view.setGridClickListener {
|
||||
if (conferenceModel.participantDevices.value.orEmpty().size < 7) {
|
||||
conferenceModel.changeLayout(ConferenceViewModel.GRID_LAYOUT)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
view.setActiveSpeakerClickListener {
|
||||
conferenceModel.changeLayout(ConferenceViewModel.ACTIVE_SPEAKER_LAYOUT)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
view.setAudioOnlyClickListener {
|
||||
conferenceModel.changeLayout(ConferenceViewModel.AUDIO_ONLY_LAYOUT)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
return view.root
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.widget.PopupWindow
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Participant
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallConferenceParticipantsListFragmentBinding
|
||||
import org.linphone.databinding.CallConferenceParticipantsListPopupMenuBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.call.adapter.ConferenceParticipantsListAdapter
|
||||
import org.linphone.ui.call.fragment.GenericCallFragment
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.ConfirmationDialogModel
|
||||
import org.linphone.utils.DialogUtils
|
||||
|
||||
class ConferenceParticipantsListFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Conference Participants List Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallConferenceParticipantsListFragmentBinding
|
||||
|
||||
private lateinit var viewModel: CurrentCallViewModel
|
||||
|
||||
private lateinit var adapter: ConferenceParticipantsListAdapter
|
||||
|
||||
override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? {
|
||||
if (findNavController().currentDestination?.id == R.id.conferenceAddParticipantsFragment) {
|
||||
// Holds fragment in place while new fragment slides over it
|
||||
return AnimationUtils.loadAnimation(activity, R.anim.hold)
|
||||
}
|
||||
return super.onCreateAnimation(transit, enter, nextAnim)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = ConferenceParticipantsListAdapter()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallConferenceParticipantsListFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
observeToastEvents(viewModel.conferenceModel)
|
||||
|
||||
binding.participantsList.setHasFixedSize(true)
|
||||
binding.participantsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
binding.setBackClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
binding.setAddParticipantsClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.conferenceParticipantsListFragment) {
|
||||
val action =
|
||||
ConferenceParticipantsListFragmentDirections.actionConferenceParticipantsListFragmentToConferenceAddParticipantsFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setShowMenuClickListener {
|
||||
showPopupMenu(binding.showMenu)
|
||||
}
|
||||
|
||||
viewModel.conferenceModel.participants.observe(viewLifecycleOwner) {
|
||||
Log.i("$TAG participants list updated with [${it.size}] items")
|
||||
adapter.submitList(it)
|
||||
|
||||
// Wait for adapter to have items before setting it in the RecyclerView,
|
||||
// otherwise scroll position isn't retained
|
||||
if (binding.participantsList.adapter != adapter) {
|
||||
binding.participantsList.adapter = adapter
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.conferenceModel.removeParticipantEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val displayName = pair.first
|
||||
val participant = pair.second
|
||||
showKickParticipantDialog(displayName, participant)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.isSendingVideo.observe(viewLifecycleOwner) { sending ->
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.nativePreviewWindowId = if (sending) {
|
||||
Log.i("$TAG We are sending video, setting capture preview surface")
|
||||
binding.localPreviewVideoSurface
|
||||
} else {
|
||||
Log.i("$TAG We are not sending video, clearing capture preview surface")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
(binding.root as? ViewGroup)?.doOnLayout {
|
||||
setupVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
cleanVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
|
||||
private fun showKickParticipantDialog(displayName: String, participant: Participant) {
|
||||
val model = ConfirmationDialogModel()
|
||||
val dialog = DialogUtils.getKickConferenceParticipantConfirmationDialog(
|
||||
requireActivity(),
|
||||
model,
|
||||
displayName
|
||||
)
|
||||
|
||||
model.dismissEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
model.confirmEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
coreContext.postOnCoreThread {
|
||||
viewModel.conferenceModel.kickParticipant(participant)
|
||||
}
|
||||
val message = getString(R.string.conference_participant_was_kicked_out_toast)
|
||||
val icon = R.drawable.check
|
||||
(requireActivity() as GenericActivity).showGreenToast(message, icon)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun showPopupMenu(view: View) {
|
||||
val popupView: CallConferenceParticipantsListPopupMenuBinding = DataBindingUtil.inflate(
|
||||
LayoutInflater.from(requireContext()),
|
||||
R.layout.call_conference_participants_list_popup_menu,
|
||||
null,
|
||||
false
|
||||
)
|
||||
|
||||
val popupWindow = PopupWindow(
|
||||
popupView.root,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
true
|
||||
)
|
||||
|
||||
popupView.setShareInvitationClickListener {
|
||||
val sipUri = viewModel.conferenceModel.sipUri.value.orEmpty()
|
||||
if (sipUri.isNotEmpty()) {
|
||||
Log.i("$TAG Sharing conference SIP URI [$sipUri]")
|
||||
val label = "Conference SIP address"
|
||||
AppUtils.copyToClipboard(requireContext(), label, sipUri)
|
||||
}
|
||||
|
||||
popupWindow.dismiss()
|
||||
}
|
||||
|
||||
// Elevation is for showing a shadow around the popup
|
||||
popupWindow.elevation = 20f
|
||||
popupWindow.showAsDropDown(view, 0, 0, Gravity.BOTTOM)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.model
|
||||
|
||||
import android.view.TextureView
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.ParticipantDevice
|
||||
import org.linphone.core.ParticipantDeviceListenerStub
|
||||
import org.linphone.core.StreamType
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
class ConferenceParticipantDeviceModel
|
||||
@WorkerThread
|
||||
constructor(
|
||||
val device: ParticipantDevice,
|
||||
val isMe: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "[Conference Participant Device Model]"
|
||||
}
|
||||
|
||||
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(device.address)
|
||||
|
||||
val name = avatarModel.contactName ?: device.name.orEmpty().ifEmpty {
|
||||
LinphoneUtils.getDisplayName(device.address)
|
||||
}
|
||||
|
||||
val isMuted = MutableLiveData<Boolean>()
|
||||
|
||||
val isSpeaking = MutableLiveData<Boolean>()
|
||||
|
||||
val isActiveSpeaker = MutableLiveData<Boolean>()
|
||||
|
||||
val isScreenSharing = MutableLiveData<Boolean>()
|
||||
|
||||
val isVideoAvailable = MutableLiveData<Boolean>()
|
||||
|
||||
val isThumbnailAvailable = MutableLiveData<Boolean>()
|
||||
|
||||
val isJoining = MutableLiveData<Boolean>()
|
||||
|
||||
val isInConference = MutableLiveData<Boolean>()
|
||||
|
||||
private lateinit var textureView: TextureView
|
||||
|
||||
private val deviceListener = object : ParticipantDeviceListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onStateChanged(
|
||||
participantDevice: ParticipantDevice,
|
||||
state: ParticipantDevice.State?
|
||||
) {
|
||||
Log.i(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] state changed [$state]"
|
||||
)
|
||||
when (state) {
|
||||
ParticipantDevice.State.Joining, ParticipantDevice.State.Alerting -> {
|
||||
isJoining.postValue(true)
|
||||
}
|
||||
ParticipantDevice.State.OnHold -> {
|
||||
isInConference.postValue(false)
|
||||
}
|
||||
ParticipantDevice.State.Present -> {
|
||||
isJoining.postValue(false)
|
||||
isInConference.postValue(true)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onIsMuted(participantDevice: ParticipantDevice, muted: Boolean) {
|
||||
Log.d(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] is ${if (participantDevice.isMuted) "muted" else "no longer muted"}"
|
||||
)
|
||||
isMuted.postValue(participantDevice.isMuted)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onIsSpeakingChanged(
|
||||
participantDevice: ParticipantDevice,
|
||||
speaking: Boolean
|
||||
) {
|
||||
Log.d(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] is ${if (participantDevice.isSpeaking) "speaking" else "no longer speaking"}"
|
||||
)
|
||||
isSpeaking.postValue(speaking)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onScreenSharingChanged(
|
||||
participantDevice: ParticipantDevice,
|
||||
screenSharing: Boolean
|
||||
) {
|
||||
Log.i(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] is ${if (screenSharing) "sharing its screen" else "no longer sharing its screen"}"
|
||||
)
|
||||
isScreenSharing.postValue(screenSharing)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onThumbnailStreamAvailabilityChanged(
|
||||
participantDevice: ParticipantDevice,
|
||||
available: Boolean
|
||||
) {
|
||||
Log.i(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] thumbnail availability changed to ${if (available) "available" else "not available"}"
|
||||
)
|
||||
isThumbnailAvailable.postValue(available)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onStreamAvailabilityChanged(
|
||||
participantDevice: ParticipantDevice,
|
||||
available: Boolean,
|
||||
streamType: StreamType?
|
||||
) {
|
||||
if (streamType == StreamType.Video) {
|
||||
Log.i(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] video stream availability changed to ${if (available) "available" else "not available"}"
|
||||
)
|
||||
isVideoAvailable.postValue(available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
device.addListener(deviceListener)
|
||||
|
||||
val state = device.state
|
||||
val joining = state == ParticipantDevice.State.Joining || state == ParticipantDevice.State.Alerting
|
||||
isJoining.postValue(joining)
|
||||
val inConference = device.isInConference
|
||||
isInConference.postValue(inConference)
|
||||
if (joining) {
|
||||
Log.i(
|
||||
"$TAG Participant [${device.address.asStringUriOnly()}] is joining the conference (state [$state])"
|
||||
)
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG Participant [${device.address.asStringUriOnly()}] is [${if (inConference) "inside" else "outside"}] the conference with state [${device.state}]"
|
||||
)
|
||||
}
|
||||
|
||||
isMuted.postValue(device.isMuted)
|
||||
isSpeaking.postValue(device.isSpeaking)
|
||||
Log.i(
|
||||
"$TAG Participant [${device.address.asStringUriOnly()}] is in state [${device.state}]"
|
||||
)
|
||||
|
||||
isActiveSpeaker.postValue(false)
|
||||
val screenSharing = device.isScreenSharingEnabled
|
||||
isScreenSharing.postValue(screenSharing)
|
||||
if (screenSharing) {
|
||||
Log.i("$TAG Participant [${device.address.asStringUriOnly()}] is sharing its screen")
|
||||
}
|
||||
|
||||
val videoAvailability = device.getStreamAvailability(StreamType.Video)
|
||||
isVideoAvailable.postValue(videoAvailability)
|
||||
Log.i(
|
||||
"$TAG Participant device [${device.address.asStringUriOnly()}] video stream availability is ${if (videoAvailability) "available" else "not available"}"
|
||||
)
|
||||
|
||||
// In case of joining conference without bundle mode, thumbnail stream availability will be false,
|
||||
// but we need to display our video preview for video stream to be sent
|
||||
val thumbnailVideoAvailability = if (isMe) videoAvailability else device.thumbnailStreamAvailability
|
||||
isThumbnailAvailable.postValue(thumbnailVideoAvailability)
|
||||
Log.i(
|
||||
"$TAG Participant device [${device.address.asStringUriOnly()}] thumbnail availability is ${if (thumbnailVideoAvailability) "available" else "not available"}"
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun destroy() {
|
||||
clearWindowId()
|
||||
device.removeListener(deviceListener)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun clearWindowId() {
|
||||
Log.i("$TAG Clearing participant [${device.address.asStringUriOnly()}] device window ID")
|
||||
device.nativeVideoWindowId = null
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun setTextureView(view: TextureView) {
|
||||
Log.i(
|
||||
"$TAG TextureView for participant [${device.address.asStringUriOnly()}] available from UI [$view]"
|
||||
)
|
||||
textureView = view
|
||||
coreContext.postOnCoreThread {
|
||||
updateWindowId()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateWindowId() {
|
||||
if (::textureView.isInitialized) {
|
||||
// SDK does it but it's a bit better this way, prevents going to participants map in PlatformHelper for nothing
|
||||
if (isMe) {
|
||||
Log.i(
|
||||
"$TAG Setting our own video preview window ID [$textureView]"
|
||||
)
|
||||
coreContext.core.nativePreviewWindowId = textureView
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG Setting participant [${device.address.asStringUriOnly()}] window ID [$textureView]"
|
||||
)
|
||||
device.nativeVideoWindowId = textureView
|
||||
}
|
||||
} else {
|
||||
if (isMe) {
|
||||
Log.e("$TAG Our TextureView wasn't initialized yet!")
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG TextureView for participant [${device.address.asStringUriOnly()}] wasn't initialized yet!"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.model
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Participant
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.main.contacts.model.ContactAvatarModel
|
||||
|
||||
class ConferenceParticipantModel
|
||||
@WorkerThread
|
||||
constructor(
|
||||
val participant: Participant,
|
||||
val avatarModel: ContactAvatarModel,
|
||||
isMyselfAdmin: Boolean,
|
||||
val isMyself: Boolean,
|
||||
private val removeFromConference: ((participant: Participant) -> Unit)?,
|
||||
private val changeAdminStatus: ((participant: Participant, setAdmin: Boolean) -> Unit)?
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "[Conference Participant Model]"
|
||||
}
|
||||
|
||||
val sipUri = participant.address.asStringUriOnly()
|
||||
|
||||
val isAdmin = MutableLiveData<Boolean>()
|
||||
|
||||
val isMeAdmin = MutableLiveData<Boolean>()
|
||||
|
||||
init {
|
||||
isAdmin.postValue(participant.isAdmin)
|
||||
isMeAdmin.postValue(isMyselfAdmin)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun removeParticipant() {
|
||||
Log.w("$TAG Removing participant from conference")
|
||||
coreContext.postOnCoreThread {
|
||||
removeFromConference?.invoke(participant)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun toggleAdminStatus() {
|
||||
val newStatus = isAdmin.value == false
|
||||
Log.w(
|
||||
"$TAG Changing participant admin status to ${if (newStatus) "admin" else "not admin"}"
|
||||
)
|
||||
isAdmin.postValue(newStatus)
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
changeAdminStatus?.invoke(participant, newStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2022 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.view
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.GridLayout
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.view.children
|
||||
import org.linphone.core.tools.Log
|
||||
import androidx.core.view.isEmpty
|
||||
|
||||
@UiThread
|
||||
class GridBoxLayout : GridLayout {
|
||||
companion object {
|
||||
private const val TAG = "[Grid Box Layout]"
|
||||
|
||||
const val MAX_CHILD = 6
|
||||
|
||||
private val placementMatrix = arrayOf(
|
||||
intArrayOf(1, 2, 3, 4, 5, 6),
|
||||
intArrayOf(1, 1, 2, 2, 3, 3),
|
||||
intArrayOf(1, 1, 1, 2, 2, 2),
|
||||
intArrayOf(1, 1, 1, 1, 2, 2),
|
||||
intArrayOf(1, 1, 1, 1, 1, 2),
|
||||
intArrayOf(1, 1, 1, 1, 1, 1)
|
||||
)
|
||||
}
|
||||
|
||||
constructor(context: Context) : this(context, null)
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
|
||||
context,
|
||||
attrs,
|
||||
defStyleAttr
|
||||
)
|
||||
|
||||
private var centerContent: Boolean = true
|
||||
private var previousChildCount = 0
|
||||
private var previousCellSize = 0
|
||||
|
||||
@SuppressLint("DrawAllocation")
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
if (isEmpty() || (!changed && previousChildCount == childCount)) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
// To prevent display issue the first time conference is locally paused
|
||||
children.forEach { child ->
|
||||
child.post {
|
||||
child.layoutParams.width = previousCellSize
|
||||
child.layoutParams.height = previousCellSize
|
||||
child.requestLayout()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// To prevent java.lang.IllegalArgumentException: columnCount must be greater than or equal
|
||||
// to the maximum of all grid indices (and spans) defined in the LayoutParams of each child.
|
||||
children.forEach { child ->
|
||||
child.layoutParams = LayoutParams()
|
||||
}
|
||||
|
||||
val maxChild = placementMatrix[0].size
|
||||
if (childCount > maxChild) {
|
||||
Log.e(
|
||||
"$TAG $childCount children but placementMatrix only knows how to display $maxChild (max allowed participants for grid layout in settings is 6)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val availableSize = Pair(right - left, bottom - top)
|
||||
var cellSize = 0
|
||||
for (index in 1..childCount) {
|
||||
val neededColumns = placementMatrix[index - 1][childCount - 1]
|
||||
val candidateWidth = 1 * availableSize.first / neededColumns
|
||||
val candidateHeight = 1 * availableSize.second / index
|
||||
val candidateSize = if (candidateWidth < candidateHeight) candidateWidth else candidateHeight
|
||||
if (candidateSize > cellSize) {
|
||||
columnCount = neededColumns
|
||||
rowCount = index
|
||||
cellSize = candidateSize
|
||||
}
|
||||
}
|
||||
previousCellSize = cellSize
|
||||
previousChildCount = childCount
|
||||
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
children.forEach { child ->
|
||||
child.layoutParams.width = cellSize
|
||||
child.layoutParams.height = cellSize
|
||||
child.post {
|
||||
child.requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
if (centerContent) {
|
||||
setPadding(
|
||||
(availableSize.first - (columnCount * cellSize)) / 2,
|
||||
(availableSize.second - (rowCount * cellSize)) / 2,
|
||||
(availableSize.first - (columnCount * cellSize)) / 2,
|
||||
(availableSize.second - (rowCount * cellSize)) / 2
|
||||
)
|
||||
}
|
||||
Log.d(
|
||||
"$TAG cellsize=$cellSize columns=$columnCount rows=$rowCount availablesize=$availableSize"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,852 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.conference.viewmodel
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.Conference
|
||||
import org.linphone.core.ConferenceListenerStub
|
||||
import org.linphone.core.MediaDirection
|
||||
import org.linphone.core.Participant
|
||||
import org.linphone.core.ParticipantDevice
|
||||
import org.linphone.core.StreamType
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.ui.call.conference.model.ConferenceParticipantDeviceModel
|
||||
import org.linphone.ui.call.conference.model.ConferenceParticipantModel
|
||||
import org.linphone.ui.call.conference.view.GridBoxLayout
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
class ConferenceViewModel
|
||||
@UiThread
|
||||
constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Conference ViewModel]"
|
||||
|
||||
const val AUDIO_ONLY_LAYOUT = -1
|
||||
const val GRID_LAYOUT = 0 // Conference.Layout.Grid
|
||||
const val ACTIVE_SPEAKER_LAYOUT = 1 // Conference.Layout.ActiveSpeaker
|
||||
}
|
||||
|
||||
val subject = MutableLiveData<String>()
|
||||
|
||||
val sipUri = MutableLiveData<String>()
|
||||
|
||||
val participants = MutableLiveData<ArrayList<ConferenceParticipantModel>>()
|
||||
|
||||
val participantDevices = MutableLiveData<ArrayList<ConferenceParticipantDeviceModel>>()
|
||||
|
||||
val participantsLabel = MutableLiveData<String>()
|
||||
|
||||
val activeSpeaker = MutableLiveData<ConferenceParticipantDeviceModel>()
|
||||
|
||||
val isCurrentCallInConference = MutableLiveData<Boolean>()
|
||||
|
||||
val conferenceLayout = MutableLiveData<Int>()
|
||||
|
||||
val screenSharingParticipantName = MutableLiveData<String>()
|
||||
|
||||
val isScreenSharing = MutableLiveData<Boolean>()
|
||||
|
||||
val isPaused = MutableLiveData<Boolean>()
|
||||
|
||||
val isMeParticipantSendingVideo = MutableLiveData<Boolean>()
|
||||
|
||||
val isMeAdmin = MutableLiveData<Boolean>()
|
||||
|
||||
val isConversationAvailable = MutableLiveData<Boolean>()
|
||||
|
||||
val fullScreenMode = MutableLiveData<Boolean>()
|
||||
|
||||
val firstParticipantOtherThanOurselvesJoinedEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
val showLayoutMenuEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
val removeParticipantEvent: MutableLiveData<Event<Pair<String, Participant>>> by lazy {
|
||||
MutableLiveData<Event<Pair<String, Participant>>>()
|
||||
}
|
||||
|
||||
val goToConversationEvent: MutableLiveData<Event<String>> by lazy {
|
||||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
||||
private lateinit var conference: Conference
|
||||
|
||||
private val conferenceListener = object : ConferenceListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onParticipantAdded(conference: Conference, participant: Participant) {
|
||||
Log.i(
|
||||
"$TAG Participant added: ${participant.address.asStringUriOnly()}"
|
||||
)
|
||||
addParticipant(participant)
|
||||
|
||||
if (conference.participantList.size == 1) { // we do not count
|
||||
Log.i("$TAG First participant other than ourselves joined the conference")
|
||||
firstParticipantOtherThanOurselvesJoinedEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onParticipantRemoved(conference: Conference, participant: Participant) {
|
||||
Log.i(
|
||||
"$TAG Participant removed: ${participant.address.asStringUriOnly()}"
|
||||
)
|
||||
removeParticipant(participant)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onParticipantDeviceMediaCapabilityChanged(
|
||||
conference: Conference,
|
||||
device: ParticipantDevice
|
||||
) {
|
||||
if (device.isMe) {
|
||||
Log.i("$TAG Our device media capability changed")
|
||||
val direction = device.getStreamCapability(StreamType.Video)
|
||||
val sendingVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly
|
||||
localVideoStreamToggled(sendingVideo)
|
||||
} else {
|
||||
Log.i("$TAG Participant [${device.address.asStringUriOnly()}] device media capability changed")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onActiveSpeakerParticipantDevice(
|
||||
conference: Conference,
|
||||
participantDevice: ParticipantDevice?
|
||||
) {
|
||||
activeSpeaker.value?.isActiveSpeaker?.postValue(false)
|
||||
|
||||
if (participantDevice != null) {
|
||||
val found = participantDevices.value.orEmpty().find {
|
||||
it.device.address.equal(participantDevice.address)
|
||||
}
|
||||
if (found != null) {
|
||||
Log.i("$TAG Newly active speaker participant is [${found.name}]")
|
||||
found.isActiveSpeaker.postValue(true)
|
||||
activeSpeaker.postValue(found!!)
|
||||
} else {
|
||||
Log.i("$TAG Failed to find actively speaking participant...")
|
||||
val model = ConferenceParticipantDeviceModel(participantDevice)
|
||||
model.isActiveSpeaker.postValue(true)
|
||||
activeSpeaker.postValue(model)
|
||||
}
|
||||
} else {
|
||||
Log.w("$TAG Notified active speaker participant device is null, using first one that's not us")
|
||||
val firstNotUs = participantDevices.value.orEmpty().find {
|
||||
!it.isMe
|
||||
}
|
||||
if (firstNotUs != null) {
|
||||
Log.i("$TAG Newly active speaker participant is [${firstNotUs.name}]")
|
||||
firstNotUs.isActiveSpeaker.postValue(true)
|
||||
activeSpeaker.postValue(firstNotUs!!)
|
||||
} else {
|
||||
Log.i("$TAG No participant device that's not us found, expected if we're alone")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onParticipantAdminStatusChanged(
|
||||
conference: Conference,
|
||||
participant: Participant
|
||||
) {
|
||||
// Only recompute participants list
|
||||
computeParticipants(true)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onParticipantDeviceAdded(
|
||||
conference: Conference,
|
||||
participantDevice: ParticipantDevice
|
||||
) {
|
||||
Log.i(
|
||||
"$TAG Participant device added: ${participantDevice.address.asStringUriOnly()}"
|
||||
)
|
||||
|
||||
// Since we do not compute our own devices until another participant joins,
|
||||
// We have to do it when someone else joins
|
||||
if (participantDevices.value.orEmpty().isEmpty()) {
|
||||
val list = arrayListOf<ConferenceParticipantDeviceModel>()
|
||||
val ourDevices = conference.me.devices
|
||||
Log.i("$TAG We have [${ourDevices.size}] devices, now it's time to add them")
|
||||
for (device in ourDevices) {
|
||||
val model = ConferenceParticipantDeviceModel(device, true)
|
||||
list.add(model)
|
||||
}
|
||||
|
||||
val newModel = ConferenceParticipantDeviceModel(participantDevice)
|
||||
list.add(newModel)
|
||||
participantDevices.postValue(sortParticipantDevicesList(list))
|
||||
} else {
|
||||
addParticipantDevice(participantDevice)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onParticipantDeviceRemoved(
|
||||
conference: Conference,
|
||||
participantDevice: ParticipantDevice
|
||||
) {
|
||||
Log.i(
|
||||
"$TAG Participant device removed: ${participantDevice.address.asStringUriOnly()}"
|
||||
)
|
||||
removeParticipantDevice(participantDevice)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onParticipantDeviceStateChanged(
|
||||
conference: Conference,
|
||||
device: ParticipantDevice,
|
||||
state: ParticipantDevice.State
|
||||
) {
|
||||
Log.i(
|
||||
"$TAG Participant device [${device.address.asStringUriOnly()}] state changed [$state]"
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onParticipantDeviceScreenSharingChanged(
|
||||
conference: Conference,
|
||||
device: ParticipantDevice,
|
||||
enabled: Boolean
|
||||
) {
|
||||
Log.i(
|
||||
"$TAG Participant device [${device.address.asStringUriOnly()}] is ${if (enabled) "sharing it's screen" else "no longer sharing it's screen"}"
|
||||
)
|
||||
isScreenSharing.postValue(enabled)
|
||||
|
||||
if (enabled) {
|
||||
val deviceModel = participantDevices.value.orEmpty().find {
|
||||
it.device == device || device.address.weakEqual(it.device.address)
|
||||
}
|
||||
if (deviceModel != null) {
|
||||
screenSharingParticipantName.postValue(deviceModel.name)
|
||||
} else {
|
||||
Log.w("$TAG Failed to find screen sharing participant device model!")
|
||||
}
|
||||
|
||||
val call = conference.call
|
||||
if (call != null) {
|
||||
val currentLayout = getCurrentLayout(call)
|
||||
if (currentLayout == GRID_LAYOUT) {
|
||||
Log.w(
|
||||
"$TAG Current layout is mosaic but screen sharing was enabled, switching to active speaker layout"
|
||||
)
|
||||
setNewLayout(ACTIVE_SPEAKER_LAYOUT)
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Screen sharing was enabled but conference's call is null!")
|
||||
}
|
||||
} else {
|
||||
screenSharingParticipantName.postValue("")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onStateChanged(conference: Conference, state: Conference.State) {
|
||||
Log.i("$TAG State changed [$state]")
|
||||
if (conference.state == Conference.State.Created) {
|
||||
val isIn = conference.isIn
|
||||
isPaused.postValue(!isIn)
|
||||
Log.i("$TAG We [${if (isIn) "are" else "aren't"}] in the conference")
|
||||
|
||||
subject.postValue(conference.subjectUtf8.orEmpty())
|
||||
computeParticipants(false)
|
||||
if (conference.participantList.size >= 1) { // we do not count
|
||||
Log.i("$TAG Joined conference already has at least another participant")
|
||||
firstParticipantOtherThanOurselvesJoinedEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
isPaused.value = false
|
||||
isConversationAvailable.value = false
|
||||
isMeParticipantSendingVideo.value = false
|
||||
fullScreenMode.value = false
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun destroy() {
|
||||
isCurrentCallInConference.postValue(false)
|
||||
if (::conference.isInitialized) {
|
||||
conference.removeListener(conferenceListener)
|
||||
participantDevices.value.orEmpty().forEach(ConferenceParticipantDeviceModel::destroy)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun configureFromCall(call: Call) {
|
||||
val conf = call.conference ?: return
|
||||
if (::conference.isInitialized) {
|
||||
conference.removeListener(conferenceListener)
|
||||
}
|
||||
|
||||
isCurrentCallInConference.postValue(true)
|
||||
conference = conf
|
||||
conference.addListener(conferenceListener)
|
||||
|
||||
val isIn = conference.isIn
|
||||
val state = conf.state
|
||||
if (state != Conference.State.CreationPending) {
|
||||
isPaused.postValue(!isIn)
|
||||
}
|
||||
Log.i(
|
||||
"$TAG We [${if (isIn) "are" else "aren't"}] in the conference right now, current state is [$state]"
|
||||
)
|
||||
|
||||
val screenSharing = conference.screenSharingParticipant != null
|
||||
isScreenSharing.postValue(screenSharing)
|
||||
|
||||
val chatEnabled = conference.currentParams.isChatEnabled
|
||||
isConversationAvailable.postValue(chatEnabled)
|
||||
|
||||
val confSubject = conference.subjectUtf8.orEmpty()
|
||||
Log.i(
|
||||
"$TAG Configuring conference with subject [$confSubject] from call [${call.callLog.callId}]"
|
||||
)
|
||||
sipUri.postValue(conference.conferenceAddress?.asStringUriOnly())
|
||||
subject.postValue(confSubject)
|
||||
|
||||
if (conference.state == Conference.State.Created) {
|
||||
computeParticipants(false)
|
||||
if (conference.participantList.size >= 1) { // we do not count
|
||||
Log.i("$TAG Joined conference already has at least another participant")
|
||||
firstParticipantOtherThanOurselvesJoinedEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
|
||||
val currentLayout = getCurrentLayout(call)
|
||||
conferenceLayout.postValue(currentLayout)
|
||||
if (currentLayout == GRID_LAYOUT && screenSharing) {
|
||||
Log.w(
|
||||
"$TAG Conference has a participant sharing its screen, changing layout from mosaic to active speaker"
|
||||
)
|
||||
setNewLayout(ACTIVE_SPEAKER_LAYOUT)
|
||||
} else if (currentLayout == AUDIO_ONLY_LAYOUT) {
|
||||
val defaultLayout = call.core.defaultConferenceLayout.toInt()
|
||||
if (defaultLayout == Conference.Layout.ActiveSpeaker.toInt()) {
|
||||
Log.w("$TAG Joined conference in audio only layout, switching to active speaker layout")
|
||||
setNewLayout(ACTIVE_SPEAKER_LAYOUT)
|
||||
} else {
|
||||
Log.w("$TAG Joined conference in audio only layout, switching to grid layout")
|
||||
setNewLayout(GRID_LAYOUT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun toggleFullScreen() {
|
||||
if (fullScreenMode.value == true) {
|
||||
// Always allow to switch off full screen mode
|
||||
fullScreenMode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (conferenceLayout.value == AUDIO_ONLY_LAYOUT) {
|
||||
// Do not allow turning full screen on for audio only conference
|
||||
return
|
||||
}
|
||||
|
||||
if (isMeParticipantSendingVideo.value == false && participants.value.orEmpty().size == 1) {
|
||||
// Do not allow turning full screen on if we're alone and not sending our video
|
||||
return
|
||||
}
|
||||
|
||||
fullScreenMode.value = true
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun localVideoStreamToggled(enabled: Boolean) {
|
||||
isMeParticipantSendingVideo.postValue(enabled)
|
||||
Log.i("$TAG We [${if (enabled) "are" else "aren't"}] sending video")
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun goToConversation() {
|
||||
if (::conference.isInitialized) {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Navigating to conference's conversation")
|
||||
val chatRoom = conference.chatRoom
|
||||
if (chatRoom != null) {
|
||||
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG No chat room available for current conference [${conference.conferenceAddress?.asStringUriOnly()}]"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun showLayoutMenu() {
|
||||
showLayoutMenuEvent.value = Event(true)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun changeLayout(newLayout: Int) {
|
||||
coreContext.postOnCoreThread {
|
||||
setNewLayout(newLayout)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun inviteSipUrisIntoConference(uris: List<String>) {
|
||||
if (::conference.isInitialized) {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
val addresses = arrayListOf<Address>()
|
||||
for (uri in uris) {
|
||||
val address = core.interpretUrl(uri, false)
|
||||
if (address != null) {
|
||||
addresses.add(address)
|
||||
Log.i("$TAG Address [${address.asStringUriOnly()}] will be added to conference")
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG Failed to parse SIP URI [$uri] into address, can't add it to the conference!"
|
||||
)
|
||||
showRedToast(
|
||||
R.string.conference_failed_to_add_participant_invalid_address_toast,
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
val addressesArray = arrayOfNulls<Address>(addresses.size)
|
||||
addresses.toArray(addressesArray)
|
||||
Log.i("$TAG Trying to add [${addressesArray.size}] new participant(s) into conference")
|
||||
conference.addParticipants(addressesArray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun kickParticipant(participant: Participant) {
|
||||
if (::conference.isInitialized) {
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i(
|
||||
"$TAG Kicking participant [${participant.address.asStringUriOnly()}] out of conference"
|
||||
)
|
||||
conference.removeParticipant(participant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun setNewLayout(newLayout: Int) {
|
||||
if (::conference.isInitialized) {
|
||||
val call = conference.call
|
||||
if (call != null) {
|
||||
val params = call.core.createCallParams(call)
|
||||
if (params != null) {
|
||||
val currentLayout = getCurrentLayout(call)
|
||||
if (currentLayout != newLayout) {
|
||||
when (newLayout) {
|
||||
AUDIO_ONLY_LAYOUT -> {
|
||||
Log.i("$TAG Changing conference layout to [Audio Only]")
|
||||
params.isVideoEnabled = false
|
||||
}
|
||||
|
||||
ACTIVE_SPEAKER_LAYOUT -> {
|
||||
Log.i("$TAG Changing conference layout to [Active Speaker]")
|
||||
params.conferenceVideoLayout = Conference.Layout.ActiveSpeaker
|
||||
}
|
||||
|
||||
GRID_LAYOUT -> {
|
||||
Log.i("$TAG Changing conference layout to [Grid]")
|
||||
params.conferenceVideoLayout = Conference.Layout.Grid
|
||||
}
|
||||
}
|
||||
|
||||
Log.i("$TAG Clearing participant devices window IDs")
|
||||
participantDevices.value.orEmpty().forEach(ConferenceParticipantDeviceModel::clearWindowId)
|
||||
|
||||
if (currentLayout == AUDIO_ONLY_LAYOUT) {
|
||||
// Previous layout was audio only, make sure video isn't sent without user consent when switching layout
|
||||
Log.i(
|
||||
"$TAG Previous layout was [Audio Only], enabling video but in receive only direction"
|
||||
)
|
||||
params.isVideoEnabled = true
|
||||
params.videoDirection = MediaDirection.RecvOnly
|
||||
}
|
||||
|
||||
Log.i("$TAG Updating conference's call params")
|
||||
call.update(params)
|
||||
conferenceLayout.postValue(newLayout)
|
||||
} else {
|
||||
Log.w(
|
||||
"$TAG The conference is already using selected layout, aborting layout change"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Failed to create call params, aborting layout change")
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Failed to get call from conference, aborting layout change")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getCurrentLayout(call: Call): Int {
|
||||
// DO NOT USE call.currentParams, information won't be reliable !
|
||||
return if (!call.params.isVideoEnabled) {
|
||||
Log.i("$TAG Current conference layout is [Audio Only]")
|
||||
AUDIO_ONLY_LAYOUT
|
||||
} else {
|
||||
when (val layout = call.params.conferenceVideoLayout) {
|
||||
Conference.Layout.Grid -> {
|
||||
Log.i("$TAG Current conference layout is [Grid]")
|
||||
GRID_LAYOUT
|
||||
}
|
||||
Conference.Layout.ActiveSpeaker -> {
|
||||
Log.i("$TAG Current conference layout is [Active Speaker]")
|
||||
ACTIVE_SPEAKER_LAYOUT
|
||||
}
|
||||
else -> {
|
||||
Log.e("$TAG Unexpected conference layout value [$layout]")
|
||||
-2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun computeParticipants(skipDevices: Boolean) {
|
||||
if (!skipDevices) {
|
||||
participantDevices.value.orEmpty().forEach(ConferenceParticipantDeviceModel::destroy)
|
||||
}
|
||||
|
||||
val participantsList = arrayListOf<ConferenceParticipantModel>()
|
||||
val devicesList = arrayListOf<ConferenceParticipantDeviceModel>()
|
||||
|
||||
val conferenceParticipants = conference.participantList
|
||||
Log.i("$TAG [${conferenceParticipants.size}] participant in conference")
|
||||
|
||||
val meParticipant = conference.me
|
||||
val admin = meParticipant.isAdmin
|
||||
isMeAdmin.postValue(admin)
|
||||
if (admin) {
|
||||
Log.i("$TAG We are admin of that conference!")
|
||||
}
|
||||
|
||||
var activeSpeakerParticipantDeviceFound = false
|
||||
for (participant in conferenceParticipants) {
|
||||
val devices = participant.devices
|
||||
val role = participant.role
|
||||
|
||||
Log.i(
|
||||
"$TAG Participant [${participant.address.asStringUriOnly()}] has [${devices.size}] devices and role [${role.name}]"
|
||||
)
|
||||
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(
|
||||
participant.address
|
||||
)
|
||||
val participantModel = ConferenceParticipantModel(
|
||||
participant,
|
||||
avatarModel,
|
||||
admin,
|
||||
false,
|
||||
{ participant -> // Remove from conference
|
||||
removeParticipantEvent.postValue(
|
||||
Event(Pair(avatarModel.name.value.orEmpty(), participant))
|
||||
)
|
||||
},
|
||||
{ participant, setAdmin -> // Change admin status
|
||||
conference.setParticipantAdminStatus(participant, setAdmin)
|
||||
}
|
||||
)
|
||||
participantsList.add(participantModel)
|
||||
|
||||
if (role == Participant.Role.Listener) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!skipDevices) {
|
||||
for (device in devices) {
|
||||
val model = ConferenceParticipantDeviceModel(device)
|
||||
devicesList.add(model)
|
||||
|
||||
if (device == conference.activeSpeakerParticipantDevice) {
|
||||
Log.i("$TAG Using participant is [${model.name}] as current active speaker")
|
||||
model.isActiveSpeaker.postValue(true)
|
||||
activeSpeaker.postValue(model)
|
||||
activeSpeakerParticipantDeviceFound = true
|
||||
}
|
||||
if (device == conference.screenSharingParticipantDevice) {
|
||||
Log.i("$TAG Using participant is [${model.name}] as current screen sharing sender")
|
||||
screenSharingParticipantName.postValue(model.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skipDevices) {
|
||||
Log.i(
|
||||
"$TAG [${participantsList.size}] participants will be displayed (not counting ourselves), devices were skipped"
|
||||
)
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG [${devicesList.size}] participant devices for [${participantsList.size}] participants will be displayed (not counting ourselves)"
|
||||
)
|
||||
}
|
||||
|
||||
val meAvatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(
|
||||
meParticipant.address
|
||||
)
|
||||
val meParticipantModel = ConferenceParticipantModel(
|
||||
meParticipant,
|
||||
meAvatarModel,
|
||||
admin,
|
||||
true,
|
||||
null,
|
||||
null
|
||||
)
|
||||
participantsList.add(meParticipantModel)
|
||||
|
||||
val ourDevices = conference.me.devices
|
||||
Log.i("$TAG We have [${ourDevices.size}] devices")
|
||||
for (device in ourDevices) {
|
||||
if (!skipDevices) {
|
||||
val model = ConferenceParticipantDeviceModel(device, true)
|
||||
devicesList.add(model)
|
||||
|
||||
if (device == conference.activeSpeakerParticipantDevice) {
|
||||
Log.i("$TAG Using our device [${model.name}] as current active speaker")
|
||||
model.isActiveSpeaker.postValue(true)
|
||||
activeSpeaker.postValue(model)
|
||||
activeSpeakerParticipantDeviceFound = true
|
||||
}
|
||||
}
|
||||
|
||||
val direction = device.getStreamCapability(StreamType.Video)
|
||||
val sendingVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly
|
||||
localVideoStreamToggled(sendingVideo)
|
||||
}
|
||||
|
||||
if (!activeSpeakerParticipantDeviceFound && devicesList.isNotEmpty()) {
|
||||
val first = devicesList.first()
|
||||
Log.w(
|
||||
"$TAG Failed to find current active speaker participant device, using first one [${first.name}]"
|
||||
)
|
||||
first.isActiveSpeaker.postValue(true)
|
||||
activeSpeaker.postValue(first)
|
||||
}
|
||||
|
||||
participants.postValue(sortParticipantList(participantsList))
|
||||
if (!skipDevices) {
|
||||
checkIfTooManyParticipantDevicesForGridLayout(devicesList)
|
||||
|
||||
if (participantsList.size == 1) {
|
||||
Log.i("$TAG We are alone in that conference, not posting devices list for now")
|
||||
participantDevices.postValue(arrayListOf())
|
||||
} else {
|
||||
participantDevices.postValue(sortParticipantDevicesList(devicesList))
|
||||
}
|
||||
|
||||
participantsLabel.postValue(
|
||||
AppUtils.getStringWithPlural(
|
||||
R.plurals.conference_participants_list_title,
|
||||
participantsList.size,
|
||||
"${participantsList.size}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun sortParticipantList(devices: List<ConferenceParticipantModel>): ArrayList<ConferenceParticipantModel> {
|
||||
val sortedList = arrayListOf<ConferenceParticipantModel>()
|
||||
sortedList.addAll(devices)
|
||||
|
||||
val meModel = sortedList.find {
|
||||
it.isMyself
|
||||
}
|
||||
if (meModel != null) {
|
||||
val index = sortedList.indexOf(meModel)
|
||||
val expectedIndex = 0
|
||||
if (index != expectedIndex) {
|
||||
Log.i(
|
||||
"$TAG Me participant model is at index $index, moving it to index $expectedIndex"
|
||||
)
|
||||
sortedList.removeAt(index)
|
||||
sortedList.add(expectedIndex, meModel)
|
||||
}
|
||||
}
|
||||
|
||||
return sortedList
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun sortParticipantDevicesList(devices: List<ConferenceParticipantDeviceModel>): ArrayList<ConferenceParticipantDeviceModel> {
|
||||
val sortedList = arrayListOf<ConferenceParticipantDeviceModel>()
|
||||
sortedList.addAll(devices)
|
||||
|
||||
val meDeviceModel = sortedList.find {
|
||||
it.isMe
|
||||
}
|
||||
if (meDeviceModel != null) {
|
||||
val index = sortedList.indexOf(meDeviceModel)
|
||||
val expectedIndex = if (conferenceLayout.value == ACTIVE_SPEAKER_LAYOUT) {
|
||||
Log.i(
|
||||
"$TAG Current conference layout is [Active Speaker], expecting our device to be at the beginning of the list"
|
||||
)
|
||||
0
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG Current conference layout isn't [Active Speaker], expecting our device to be at the end of the list"
|
||||
)
|
||||
sortedList.size - 1
|
||||
}
|
||||
if (index != expectedIndex) {
|
||||
Log.i(
|
||||
"$TAG Me device model is at index $index, moving it to index $expectedIndex"
|
||||
)
|
||||
sortedList.removeAt(index)
|
||||
sortedList.add(expectedIndex, meDeviceModel)
|
||||
}
|
||||
}
|
||||
|
||||
return sortedList
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun addParticipant(participant: Participant) {
|
||||
if (::conference.isInitialized) {
|
||||
val list = arrayListOf<ConferenceParticipantModel>()
|
||||
list.addAll(participants.value.orEmpty())
|
||||
|
||||
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(
|
||||
participant.address
|
||||
)
|
||||
val newModel = ConferenceParticipantModel(
|
||||
participant,
|
||||
avatarModel,
|
||||
isMeAdmin.value == true,
|
||||
false,
|
||||
{ participant -> // Remove from conference
|
||||
removeParticipantEvent.postValue(
|
||||
Event(Pair(avatarModel.name.value.orEmpty(), participant))
|
||||
)
|
||||
},
|
||||
{ participant, setAdmin -> // Change admin status
|
||||
conference.setParticipantAdminStatus(participant, setAdmin)
|
||||
}
|
||||
)
|
||||
list.add(newModel)
|
||||
|
||||
participants.postValue(sortParticipantList(list))
|
||||
participantsLabel.postValue(
|
||||
AppUtils.getStringWithPlural(
|
||||
R.plurals.conference_participants_list_title,
|
||||
list.size,
|
||||
"${list.size}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun addParticipantDevice(participantDevice: ParticipantDevice) {
|
||||
val list = arrayListOf<ConferenceParticipantDeviceModel>()
|
||||
list.addAll(participantDevices.value.orEmpty())
|
||||
|
||||
val newModel = ConferenceParticipantDeviceModel(participantDevice)
|
||||
list.add(newModel)
|
||||
|
||||
checkIfTooManyParticipantDevicesForGridLayout(list)
|
||||
participantDevices.postValue(sortParticipantDevicesList(list))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun removeParticipant(participant: Participant) {
|
||||
val list = arrayListOf<ConferenceParticipantModel>()
|
||||
list.addAll(participants.value.orEmpty())
|
||||
|
||||
val toRemove = list.find {
|
||||
participant.address.weakEqual(it.participant.address)
|
||||
}
|
||||
if (toRemove != null) {
|
||||
list.remove(toRemove)
|
||||
}
|
||||
|
||||
participants.postValue(list)
|
||||
participantsLabel.postValue(
|
||||
AppUtils.getStringWithPlural(
|
||||
R.plurals.conference_participants_list_title,
|
||||
list.size,
|
||||
"${list.size}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun removeParticipantDevice(participantDevice: ParticipantDevice) {
|
||||
val list = arrayListOf<ConferenceParticipantDeviceModel>()
|
||||
list.addAll(participantDevices.value.orEmpty())
|
||||
|
||||
val toRemove = list.find {
|
||||
participantDevice.address.weakEqual(it.device.address)
|
||||
}
|
||||
if (toRemove != null) {
|
||||
toRemove.destroy()
|
||||
list.remove(toRemove)
|
||||
}
|
||||
|
||||
participantDevices.postValue(list)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun togglePause() {
|
||||
if (::conference.isInitialized) {
|
||||
if (conference.isIn) {
|
||||
Log.i("$TAG Temporary leaving conference")
|
||||
conference.leave()
|
||||
isPaused.postValue(true)
|
||||
} else {
|
||||
Log.i("$TAG Entering conference again")
|
||||
conference.enter()
|
||||
isPaused.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun checkIfTooManyParticipantDevicesForGridLayout(
|
||||
list: ArrayList<ConferenceParticipantDeviceModel>
|
||||
) {
|
||||
if (list.size > GridBoxLayout.MAX_CHILD && conferenceLayout.value == GRID_LAYOUT) {
|
||||
Log.w(
|
||||
"$TAG Too many participant devices for grid layout, switching to active speaker layout"
|
||||
)
|
||||
setNewLayout(ACTIVE_SPEAKER_LAYOUT)
|
||||
showRedToast(R.string.conference_too_many_participants_for_mosaic_layout_toast, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,504 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallActiveFragmentBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.call.CallActivity
|
||||
import org.linphone.ui.call.model.ZrtpAlertDialogModel
|
||||
import org.linphone.ui.call.model.ZrtpSasConfirmationDialogModel
|
||||
import org.linphone.ui.call.viewmodel.CallsViewModel
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.DialogUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.addCharacterAtPosition
|
||||
import org.linphone.utils.removeCharacterAtPosition
|
||||
import org.linphone.utils.startAnimatedDrawable
|
||||
|
||||
@UiThread
|
||||
class ActiveCallFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Active Call Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallActiveFragmentBinding
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
private lateinit var callsViewModel: CallsViewModel
|
||||
|
||||
private var zrtpSasDialog: Dialog? = null
|
||||
|
||||
private val actionsBottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
val drawable = AnimatedVectorDrawableCompat.create(
|
||||
requireContext(),
|
||||
R.drawable.animated_handle_to_caret
|
||||
)
|
||||
binding.bottomBar.mainActions.callActionsHandle.setImageDrawable(drawable)
|
||||
} else if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
val drawable = AnimatedVectorDrawableCompat.create(
|
||||
requireContext(),
|
||||
R.drawable.animated_caret_to_handle
|
||||
)
|
||||
binding.bottomBar.mainActions.callActionsHandle.setImageDrawable(drawable)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
|
||||
}
|
||||
|
||||
private val backPressedCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
val actionsBottomSheetBehavior = BottomSheetBehavior.from(binding.bottomBar.root)
|
||||
if (actionsBottomSheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
return
|
||||
}
|
||||
|
||||
val numpadBottomSheetBehavior = BottomSheetBehavior.from(binding.callNumpad.root)
|
||||
if (numpadBottomSheetBehavior.state != BottomSheetBehavior.STATE_HIDDEN) {
|
||||
numpadBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
val callStatsBottomSheetBehavior = BottomSheetBehavior.from(binding.callStats.root)
|
||||
if (callStatsBottomSheetBehavior.state != BottomSheetBehavior.STATE_HIDDEN) {
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
val callMediaEncryptionStatsBottomSheetBehavior = BottomSheetBehavior.from(
|
||||
binding.callMediaEncryptionStats.root
|
||||
)
|
||||
if (callMediaEncryptionStatsBottomSheetBehavior.state != BottomSheetBehavior.STATE_HIDDEN) {
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
Log.i("$TAG Back gesture/click detected, no bottom sheet is expanded, going back")
|
||||
isEnabled = false
|
||||
try {
|
||||
Log.i("$TAG Back gesture detected, going to MainActivity")
|
||||
(requireActivity() as CallActivity).goToMainActivity()
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.w(
|
||||
"$TAG Can't go back: $ise"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? {
|
||||
return when (findNavController().currentDestination?.id) {
|
||||
R.id.newCallFragment, R.id.callsListFragment, R.id.transferCallFragment, R.id.inCallConversationFragment -> {
|
||||
// Holds fragment in place while new fragment slides over it
|
||||
AnimationUtils.loadAnimation(activity, R.anim.hold)
|
||||
}
|
||||
else -> {
|
||||
super.onCreateAnimation(transit, enter, nextAnim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallActiveFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callViewModel)
|
||||
|
||||
callsViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CallsViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callsViewModel)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = callViewModel
|
||||
binding.callsViewModel = callsViewModel
|
||||
binding.numpadModel = callViewModel.numpadModel
|
||||
|
||||
val actionsBottomSheetBehavior = BottomSheetBehavior.from(binding.bottomBar.root)
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
actionsBottomSheetBehavior.addBottomSheetCallback(actionsBottomSheetCallback)
|
||||
|
||||
val numpadBottomSheetBehavior = BottomSheetBehavior.from(binding.callNumpad.root)
|
||||
numpadBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
numpadBottomSheetBehavior.skipCollapsed = true
|
||||
|
||||
val callStatsBottomSheetBehavior = BottomSheetBehavior.from(binding.callStats.root)
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callStatsBottomSheetBehavior.skipCollapsed = true
|
||||
|
||||
val callMediaEncryptionStatsBottomSheetBehavior = BottomSheetBehavior.from(
|
||||
binding.callMediaEncryptionStats.root
|
||||
)
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callMediaEncryptionStatsBottomSheetBehavior.skipCollapsed = true
|
||||
|
||||
binding.setBackClickListener {
|
||||
(requireActivity() as CallActivity).goToMainActivity()
|
||||
}
|
||||
|
||||
binding.setTransferCallClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
|
||||
val action =
|
||||
ActiveCallFragmentDirections.actionActiveCallFragmentToTransferCallFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setNewCallClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
|
||||
val action =
|
||||
ActiveCallFragmentDirections.actionActiveCallFragmentToNewCallFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setCallsListClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
|
||||
val action =
|
||||
ActiveCallFragmentDirections.actionActiveCallFragmentToCallsListFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setCallStatisticsClickListener {
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
binding.setCallMediaEncryptionStatisticsClickListener {
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
sharedViewModel.foldingState.observe(viewLifecycleOwner) { feature ->
|
||||
updateHingeRelatedConstraints(feature)
|
||||
}
|
||||
|
||||
callViewModel.fullScreenMode.observe(viewLifecycleOwner) { hide ->
|
||||
Log.i("$TAG Switching full screen mode to [${if (hide) "ON" else "OFF"}]")
|
||||
sharedViewModel.toggleFullScreenEvent.value = Event(hide)
|
||||
numpadBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
callViewModel.showZrtpSasDialogEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
showZrtpSasValidationDialog(pair.first, pair.second, false)
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.showZrtpSasCacheMismatchDialogEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
showZrtpSasValidationDialog(pair.first, pair.second, true)
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.zrtpAuthTokenVerifiedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { verified ->
|
||||
if (verified) {
|
||||
(requireActivity() as GenericActivity).showBlueToast(
|
||||
getString(R.string.call_can_be_trusted_toast),
|
||||
R.drawable.trusted,
|
||||
doNotTint = true
|
||||
)
|
||||
} else {
|
||||
// Only allow "trying again" once
|
||||
showZrtpAlertDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.callDuration.observe(viewLifecycleOwner) { duration ->
|
||||
binding.chronometer.base = SystemClock.elapsedRealtime() - (1000 * duration)
|
||||
binding.chronometer.start()
|
||||
}
|
||||
|
||||
callViewModel.toggleExtraActionsBottomSheetEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val state = actionsBottomSheetBehavior.state
|
||||
if (state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
val drawable = AnimatedVectorDrawableCompat.create(
|
||||
requireContext(),
|
||||
R.drawable.animated_caret_to_handle
|
||||
)
|
||||
binding.bottomBar.mainActions.callActionsHandle.setImageDrawable(drawable)
|
||||
binding.bottomBar.mainActions.callActionsHandle.startAnimatedDrawable()
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (state == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
val drawable = AnimatedVectorDrawableCompat.create(
|
||||
requireContext(),
|
||||
R.drawable.animated_handle_to_caret
|
||||
)
|
||||
binding.bottomBar.mainActions.callActionsHandle.setImageDrawable(drawable)
|
||||
binding.bottomBar.mainActions.callActionsHandle.startAnimatedDrawable()
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.showNumpadBottomSheetEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
numpadBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.removedCharacterAtCurrentPositionEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
binding.callNumpad.digitsHistory.removeCharacterAtPosition()
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.appendDigitToSearchBarEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { digit ->
|
||||
binding.callNumpad.digitsHistory.addCharacterAtPosition(digit)
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.isRemoteRecordingEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val isRemoteRecording = pair.first
|
||||
val displayName = pair.second
|
||||
val toastTag = "REMOTE_RECORDING"
|
||||
|
||||
if (isRemoteRecording) {
|
||||
Log.i("$TAG Showing [$displayName] is recording toast")
|
||||
val message = getString(R.string.call_remote_is_recording, displayName)
|
||||
(requireActivity() as GenericActivity).showPersistentRedToast(
|
||||
message,
|
||||
R.drawable.record_fill,
|
||||
toastTag
|
||||
)
|
||||
} else {
|
||||
Log.i("$TAG Removing [$displayName] is recording toast")
|
||||
(requireActivity() as CallActivity).removePersistentRedToast(toastTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.goToConferenceEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
|
||||
Log.i("$TAG Going to conference fragment")
|
||||
val action =
|
||||
ActiveCallFragmentDirections.actionActiveCallFragmentToActiveConferenceCallFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.isReceivingVideo.observe(viewLifecycleOwner) { receiving ->
|
||||
if (!receiving && callViewModel.fullScreenMode.value == true) {
|
||||
Log.i("$TAG We are no longer receiving video, leaving full screen mode")
|
||||
callViewModel.fullScreenMode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.isSendingVideo.observe(viewLifecycleOwner) { sending ->
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.nativePreviewWindowId = if (sending) {
|
||||
Log.i("$TAG We are sending video, setting capture preview surface")
|
||||
binding.localPreviewVideoSurface
|
||||
} else {
|
||||
Log.i("$TAG We are not sending video, clearing capture preview surface")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.goToConversationEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { conversationId ->
|
||||
if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
|
||||
Log.i(
|
||||
"$TAG Display conversation with conversation ID [$conversationId]"
|
||||
)
|
||||
val action =
|
||||
ActiveCallFragmentDirections.actionActiveCallFragmentToInCallConversationFragment(
|
||||
conversationId
|
||||
)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
backPressedCallback
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
(binding.root as? ViewGroup)?.doOnLayout {
|
||||
setupVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Fragment resuming, setting native video window ID")
|
||||
core.nativeVideoWindowId = binding.remoteVideoSurface
|
||||
|
||||
// Need to be done manually
|
||||
callViewModel.updateCallDuration()
|
||||
}
|
||||
|
||||
if (callViewModel.isZrtpAlertDialogVisible) {
|
||||
Log.i("$TAG Fragment resuming, showing ZRTP alert dialog")
|
||||
showZrtpAlertDialog()
|
||||
} else if (callViewModel.isZrtpDialogVisible) {
|
||||
Log.i("$TAG Fragment resuming, showing ZRTP SAS validation dialog")
|
||||
callViewModel.showZrtpSasDialogIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
zrtpSasDialog?.dismiss()
|
||||
zrtpSasDialog = null
|
||||
|
||||
cleanVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
|
||||
private fun updateHingeRelatedConstraints(feature: FoldingFeature) {
|
||||
Log.i("$TAG Updating constraint layout hinges: $feature")
|
||||
|
||||
val constraintLayout = binding.constraintLayout
|
||||
val set = ConstraintSet()
|
||||
set.clone(constraintLayout)
|
||||
|
||||
if (feature.isSeparating && feature.state == FoldingFeature.State.HALF_OPENED && feature.orientation == FoldingFeature.Orientation.HORIZONTAL) {
|
||||
set.setGuidelinePercent(R.id.hinge_top, 0.5f)
|
||||
set.setGuidelinePercent(R.id.hinge_bottom, 0.5f)
|
||||
callViewModel.halfOpenedFolded.value = true
|
||||
} else {
|
||||
set.setGuidelinePercent(R.id.hinge_top, 0f)
|
||||
set.setGuidelinePercent(R.id.hinge_bottom, 1f)
|
||||
callViewModel.halfOpenedFolded.value = false
|
||||
}
|
||||
|
||||
set.applyTo(constraintLayout)
|
||||
}
|
||||
|
||||
private fun showZrtpSasValidationDialog(
|
||||
authTokenToRead: String,
|
||||
authTokensToListen: List<String>,
|
||||
cacheMismatch: Boolean
|
||||
) {
|
||||
if (zrtpSasDialog != null) {
|
||||
zrtpSasDialog?.dismiss()
|
||||
}
|
||||
|
||||
val model = ZrtpSasConfirmationDialogModel(
|
||||
authTokenToRead,
|
||||
authTokensToListen,
|
||||
cacheMismatch
|
||||
)
|
||||
val dialog = DialogUtils.getZrtpSasConfirmationDialog(requireActivity(), model)
|
||||
|
||||
model.skipEvent.observe(viewLifecycleOwner) { event ->
|
||||
event.consume {
|
||||
callViewModel.skipZrtpSas()
|
||||
dialog.dismiss()
|
||||
callViewModel.isZrtpDialogVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
model.authTokenClickedEvent.observe(viewLifecycleOwner) { event ->
|
||||
event.consume { authToken ->
|
||||
callViewModel.updateZrtpSas(authToken)
|
||||
dialog.dismiss()
|
||||
callViewModel.isZrtpDialogVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
zrtpSasDialog = dialog
|
||||
callViewModel.isZrtpDialogVisible = true
|
||||
}
|
||||
|
||||
private fun showZrtpAlertDialog() {
|
||||
if (zrtpSasDialog != null) {
|
||||
zrtpSasDialog?.dismiss()
|
||||
}
|
||||
|
||||
val model = ZrtpAlertDialogModel(false)
|
||||
val dialog = DialogUtils.getZrtpAlertDialog(requireActivity(), model)
|
||||
|
||||
model.tryAgainEvent.observe(viewLifecycleOwner) { event ->
|
||||
event.consume {
|
||||
callViewModel.showZrtpSasDialogIfPossible()
|
||||
dialog.dismiss()
|
||||
callViewModel.isZrtpAlertDialogVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
model.hangUpEvent.observe(viewLifecycleOwner) { event ->
|
||||
event.consume {
|
||||
callViewModel.hangUp()
|
||||
dialog.dismiss()
|
||||
callViewModel.isZrtpAlertDialogVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
zrtpSasDialog = dialog
|
||||
callViewModel.isZrtpDialogVisible = false
|
||||
callViewModel.isZrtpAlertDialogVisible = true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import org.linphone.databinding.CallAudioDevicesBottomSheetBinding
|
||||
import org.linphone.ui.call.model.AudioDeviceModel
|
||||
|
||||
@UiThread
|
||||
class AudioDevicesMenuDialogFragment(
|
||||
private val devicesList: List<AudioDeviceModel>,
|
||||
private val onDismiss: (() -> Unit)? = null
|
||||
) : BottomSheetDialogFragment() {
|
||||
companion object {
|
||||
const val TAG = "AudioDevicesMenuDialogFragment"
|
||||
}
|
||||
|
||||
override fun onCancel(dialog: DialogInterface) {
|
||||
onDismiss?.invoke()
|
||||
super.onCancel(dialog)
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
onDismiss?.invoke()
|
||||
super.onDismiss(dialog)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
||||
// Makes sure all menu entries are visible,
|
||||
// required for landscape mode (otherwise only first item is visible)
|
||||
dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = CallAudioDevicesBottomSheetBinding.inflate(layoutInflater)
|
||||
view.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
for (device in devicesList) {
|
||||
device.dismissDialog = {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
view.devices = devicesList
|
||||
|
||||
return view.root
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import org.linphone.databinding.CallsListLongPressMenuBinding
|
||||
import org.linphone.ui.call.model.CallModel
|
||||
|
||||
class CallMenuDialogFragment(
|
||||
private val callModel: CallModel,
|
||||
private val onDismiss: (() -> Unit)? = null
|
||||
) : BottomSheetDialogFragment() {
|
||||
companion object {
|
||||
const val TAG = "CallMenuDialogFragment"
|
||||
}
|
||||
|
||||
override fun onCancel(dialog: DialogInterface) {
|
||||
onDismiss?.invoke()
|
||||
super.onCancel(dialog)
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
onDismiss?.invoke()
|
||||
super.onDismiss(dialog)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
||||
// Makes sure all menu entries are visible,
|
||||
// required for landscape mode (otherwise only first item is visible)
|
||||
dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = CallsListLongPressMenuBinding.inflate(layoutInflater)
|
||||
view.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
view.setHangUpClickListener {
|
||||
callModel.hangUp()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
view.setPauseResumeClickListener {
|
||||
callModel.togglePauseResume()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
view.isPaused = callModel.isPaused.value == true
|
||||
|
||||
return view.root
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallsListFragmentBinding
|
||||
import org.linphone.ui.call.adapter.CallsListAdapter
|
||||
import org.linphone.ui.call.viewmodel.CallsViewModel
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.ConfirmationDialogModel
|
||||
import org.linphone.utils.DialogUtils
|
||||
|
||||
class CallsListFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Calls List Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallsListFragmentBinding
|
||||
|
||||
private lateinit var viewModel: CallsViewModel
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
private lateinit var adapter: CallsListAdapter
|
||||
|
||||
private var bottomSheetDialog: BottomSheetDialogFragment? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = CallsListAdapter()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallsListFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CallsViewModel::class.java]
|
||||
}
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callViewModel)
|
||||
|
||||
binding.callsList.setHasFixedSize(true)
|
||||
binding.callsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
adapter.callLongClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
val modalBottomSheet = CallMenuDialogFragment(model) {
|
||||
// onDismiss
|
||||
adapter.resetSelection()
|
||||
}
|
||||
modalBottomSheet.show(parentFragmentManager, CallMenuDialogFragment.TAG)
|
||||
bottomSheetDialog = modalBottomSheet
|
||||
}
|
||||
}
|
||||
|
||||
adapter.callClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
coreContext.postOnCoreThread {
|
||||
model.togglePauseResume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.setBackClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
binding.setMergeCallsClickListener {
|
||||
showMergeCallsIntoConferenceConfirmationDialog()
|
||||
}
|
||||
|
||||
callViewModel.isSendingVideo.observe(viewLifecycleOwner) { sending ->
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.nativePreviewWindowId = if (sending) {
|
||||
Log.i("$TAG We are sending video, setting capture preview surface")
|
||||
binding.localPreviewVideoSurface
|
||||
} else {
|
||||
Log.i("$TAG We are not sending video, clearing capture preview surface")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.calls.observe(viewLifecycleOwner) {
|
||||
Log.i("$TAG Calls list updated with [${it.size}] items")
|
||||
adapter.submitList(it)
|
||||
|
||||
// Wait for adapter to have items before setting it in the RecyclerView,
|
||||
// otherwise scroll position isn't retained
|
||||
if (binding.callsList.adapter != adapter) {
|
||||
binding.callsList.adapter = adapter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
(binding.root as? ViewGroup)?.doOnLayout {
|
||||
setupVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
bottomSheetDialog?.dismiss()
|
||||
bottomSheetDialog = null
|
||||
|
||||
cleanVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
|
||||
private fun showMergeCallsIntoConferenceConfirmationDialog() {
|
||||
val model = ConfirmationDialogModel()
|
||||
val dialog = DialogUtils.getConfirmMergeCallsDialog(
|
||||
requireActivity(),
|
||||
model
|
||||
)
|
||||
|
||||
model.dismissEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
model.confirmEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
viewModel.mergeCallsIntoConference()
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.fileviewer.FileViewerActivity
|
||||
import org.linphone.ui.fileviewer.MediaViewerActivity
|
||||
import org.linphone.ui.main.chat.fragment.ConversationFragment
|
||||
|
||||
class ConversationFragment : ConversationFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[In-call Conversation Fragment]"
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
Log.i("$TAG Creating an in-call ConversationFragment")
|
||||
sendMessageViewModel.isCallConversation.value = true
|
||||
viewModel.isCallConversation.value = true
|
||||
|
||||
binding.setBackClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
sharedViewModel.displayFileEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { bundle ->
|
||||
if (findNavController().currentDestination?.id == R.id.inCallConversationFragment) {
|
||||
val path = bundle.getString("path", "")
|
||||
val isMedia = bundle.getBoolean("isMedia", false)
|
||||
if (path.isEmpty()) {
|
||||
Log.e("$TAG Can't navigate to file viewer for empty path!")
|
||||
return@consume
|
||||
}
|
||||
|
||||
Log.i(
|
||||
"$TAG Navigating to [${if (isMedia) "media" else "file"}] viewer fragment with path [$path]"
|
||||
)
|
||||
if (isMedia) {
|
||||
val intent = Intent(requireActivity(), MediaViewerActivity::class.java)
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
} else {
|
||||
val intent = Intent(requireActivity(), FileViewerActivity::class.java)
|
||||
intent.putExtras(bundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.addCallback
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallEndedFragmentBinding
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
|
||||
@UiThread
|
||||
class EndedCallFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Ended Call Fragment]"
|
||||
|
||||
private const val LOCALLY_TERMINATED_CALL_TIMEOUT: Long = 1000
|
||||
private const val REMOTELY_TERMINATED_CALL_TIMEOUT: Long = 2000
|
||||
}
|
||||
|
||||
private lateinit var binding: CallEndedFragmentBinding
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallEndedFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Disable back gesture / button
|
||||
requireActivity().onBackPressedDispatcher.addCallback { }
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callViewModel)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = callViewModel
|
||||
|
||||
Log.i("$TAG Showing ended call fragment")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (callViewModel.terminatedByUser) {
|
||||
Log.i(
|
||||
"$TAG Call terminated by user, waiting 1 second before finishing activity"
|
||||
)
|
||||
delay(LOCALLY_TERMINATED_CALL_TIMEOUT)
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG Call terminated by remote end, waiting 2 seconds before finishing activity"
|
||||
)
|
||||
delay(REMOTELY_TERMINATED_CALL_TIMEOUT)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Log.i("$TAG Finishing activity")
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.call.view.RoundCornersTextureView
|
||||
import org.linphone.ui.call.viewmodel.SharedCallViewModel
|
||||
|
||||
@UiThread
|
||||
abstract class GenericCallFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Generic Call Fragment]"
|
||||
}
|
||||
|
||||
protected lateinit var sharedViewModel: SharedCallViewModel
|
||||
|
||||
// For moving video preview purposes
|
||||
private val videoPreviewTouchListener = View.OnTouchListener { view, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
sharedViewModel.videoPreviewX = view.x - event.rawX
|
||||
sharedViewModel.videoPreviewY = view.y - event.rawY
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
sharedViewModel.videoPreviewX = view.x
|
||||
sharedViewModel.videoPreviewY = view.y
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
view.animate()
|
||||
.x(event.rawX + sharedViewModel.videoPreviewX)
|
||||
.y(event.rawY + sharedViewModel.videoPreviewY)
|
||||
.setDuration(0)
|
||||
.start()
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
view.performClick()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
sharedViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[SharedCallViewModel::class.java]
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
protected fun setupVideoPreview(localPreviewVideoSurface: RoundCornersTextureView) {
|
||||
if (requireActivity().isInPictureInPictureMode) {
|
||||
Log.i("$TAG Activity is in PiP mode, do not move video preview")
|
||||
return
|
||||
}
|
||||
|
||||
// To restore video preview position if possible
|
||||
if (sharedViewModel.videoPreviewX != 0f && sharedViewModel.videoPreviewY != 0f) {
|
||||
Log.i("$TAG Restoring video preview position with position X [${sharedViewModel.videoPreviewX}] and Y [${sharedViewModel.videoPreviewY}]")
|
||||
localPreviewVideoSurface.x = sharedViewModel.videoPreviewX
|
||||
localPreviewVideoSurface.y = sharedViewModel.videoPreviewY
|
||||
}
|
||||
|
||||
localPreviewVideoSurface.setOnTouchListener(videoPreviewTouchListener)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
protected fun cleanVideoPreview(localPreviewVideoSurface: RoundCornersTextureView) {
|
||||
localPreviewVideoSurface.setOnTouchListener(null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallIncomingFragmentBinding
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@UiThread
|
||||
class IncomingCallFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Incoming Call Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallIncomingFragmentBinding
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
private val marginSize = AppUtils.getDimension(R.dimen.sliding_accept_decline_call_margin)
|
||||
private val areaSize = AppUtils.getDimension(R.dimen.call_button_size) + marginSize
|
||||
private var initialX = 0f
|
||||
private var slidingButtonX = 0f
|
||||
private val slidingButtonTouchListener = View.OnTouchListener { view, event ->
|
||||
val width = binding.bottomBar.lockedScreenBottomBar.root.width.toFloat()
|
||||
val aboveAnswer = view.x + view.width > width - areaSize
|
||||
val aboveDecline = view.x < areaSize
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (initialX == 0f) {
|
||||
initialX = view.x
|
||||
}
|
||||
slidingButtonX = view.x - event.rawX
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (aboveAnswer) {
|
||||
// Accept
|
||||
callViewModel.answer()
|
||||
} else if (aboveDecline) {
|
||||
// Decline
|
||||
callViewModel.hangUp()
|
||||
} else {
|
||||
// Animate going back to initial position
|
||||
view.animate()
|
||||
.x(initialX)
|
||||
.setDuration(500)
|
||||
.start()
|
||||
}
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
view.animate()
|
||||
.x(min(max(marginSize, event.rawX + slidingButtonX), width - view.width - marginSize))
|
||||
.setDuration(0)
|
||||
.start()
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
view.performClick()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallIncomingFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callViewModel)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = callViewModel
|
||||
|
||||
callViewModel.isIncomingEarlyMedia.observe(viewLifecycleOwner) { earlyMedia ->
|
||||
if (earlyMedia) {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Incoming early-media call, setting video surface")
|
||||
core.nativeVideoWindowId = binding.remoteVideoSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.bottomBar.lockedScreenBottomBar.slidingButton.setOnTouchListener(slidingButtonTouchListener)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
callViewModel.refreshKeyguardLockedStatus()
|
||||
coreContext.notificationsManager.setIncomingCallFragmentCurrentlyDisplayed(true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
coreContext.notificationsManager.setIncomingCallFragmentCurrentlyDisplayed(false)
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.StartCallFragmentBinding
|
||||
import org.linphone.ui.main.adapter.ConversationsContactsAndSuggestionsListAdapter
|
||||
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener
|
||||
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel
|
||||
import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel
|
||||
import org.linphone.ui.main.history.viewmodel.StartCallViewModel
|
||||
import org.linphone.ui.main.model.ConversationContactOrSuggestionModel
|
||||
import org.linphone.utils.DialogUtils
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
import org.linphone.utils.RecyclerViewHeaderDecoration
|
||||
import org.linphone.utils.hideKeyboard
|
||||
import org.linphone.utils.setKeyboardInsetListener
|
||||
import org.linphone.utils.showKeyboard
|
||||
|
||||
class NewCallFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[New Call Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: StartCallFragmentBinding
|
||||
|
||||
private val viewModel: StartCallViewModel by navGraphViewModels(
|
||||
R.id.call_nav_graph
|
||||
)
|
||||
|
||||
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED || newState == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
viewModel.isNumpadVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
|
||||
}
|
||||
|
||||
private lateinit var adapter: ConversationsContactsAndSuggestionsListAdapter
|
||||
|
||||
private val listener = object : ContactNumberOrAddressClickListener {
|
||||
@UiThread
|
||||
override fun onClicked(model: ContactNumberOrAddressModel) {
|
||||
val address = model.address
|
||||
if (address != null) {
|
||||
coreContext.postOnCoreThread {
|
||||
action(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
override fun onLongPress(model: ContactNumberOrAddressModel) {
|
||||
}
|
||||
}
|
||||
|
||||
private var numberOrAddressPickerDialog: Dialog? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = ConversationsContactsAndSuggestionsListAdapter()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = StartCallFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
binding.setBackClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
binding.setHideNumpadClickListener {
|
||||
viewModel.hideNumpad()
|
||||
}
|
||||
|
||||
binding.contactsAndSuggestionsList.setHasFixedSize(true)
|
||||
|
||||
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter)
|
||||
binding.contactsAndSuggestionsList.addItemDecoration(headerItemDecoration)
|
||||
|
||||
adapter.onClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
startCall(model)
|
||||
}
|
||||
}
|
||||
|
||||
binding.contactsAndSuggestionsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
viewModel.modelsList.observe(
|
||||
viewLifecycleOwner
|
||||
) {
|
||||
Log.i("$TAG Contacts & suggestions list is ready with [${it.size}] items")
|
||||
val count = adapter.itemCount
|
||||
adapter.submitList(it)
|
||||
|
||||
// Wait for adapter to have items before setting it in the RecyclerView,
|
||||
// otherwise scroll position isn't retained
|
||||
if (binding.contactsAndSuggestionsList.adapter != adapter) {
|
||||
binding.contactsAndSuggestionsList.adapter = adapter
|
||||
}
|
||||
|
||||
if (count == 0) {
|
||||
(view.parent as? ViewGroup)?.doOnPreDraw {
|
||||
startPostponedEnterTransition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.searchFilter.observe(viewLifecycleOwner) { filter ->
|
||||
val trimmed = filter.trim()
|
||||
viewModel.applyFilter(trimmed)
|
||||
}
|
||||
|
||||
viewModel.removedCharacterAtCurrentPositionEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val selectionStart = binding.searchBar.selectionStart
|
||||
val selectionEnd = binding.searchBar.selectionEnd
|
||||
if (selectionStart > 0) {
|
||||
binding.searchBar.text =
|
||||
binding.searchBar.text?.delete(
|
||||
selectionStart - 1,
|
||||
selectionEnd
|
||||
)
|
||||
binding.searchBar.setSelection(selectionStart - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.appendDigitToSearchBarEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { digit ->
|
||||
val newValue = "${binding.searchBar.text}$digit"
|
||||
binding.searchBar.setText(newValue)
|
||||
binding.searchBar.setSelection(newValue.length)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.requestKeyboardVisibilityChangedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { show ->
|
||||
if (show) {
|
||||
// To automatically open keyboard
|
||||
binding.searchBar.showKeyboard()
|
||||
} else {
|
||||
binding.searchBar.requestFocus()
|
||||
binding.searchBar.hideKeyboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val bottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
|
||||
viewModel.isNumpadVisible.observe(viewLifecycleOwner) { visible ->
|
||||
if (visible) {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
||||
binding.root.setKeyboardInsetListener { keyboardVisible ->
|
||||
if (keyboardVisible) {
|
||||
viewModel.isNumpadVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
if (corePreferences.automaticallyShowDialpad) {
|
||||
viewModel.isNumpadVisible.postValue(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
numberOrAddressPickerDialog?.dismiss()
|
||||
numberOrAddressPickerDialog = null
|
||||
}
|
||||
|
||||
private fun startCall(model: ConversationContactOrSuggestionModel) {
|
||||
coreContext.postOnCoreThread {
|
||||
val friend = model.friend
|
||||
if (friend == null) {
|
||||
action(model.address)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend)
|
||||
if (singleAvailableAddress != null) {
|
||||
Log.i(
|
||||
"$TAG Only 1 SIP address or phone number found for contact [${friend.name}], starting call directly"
|
||||
)
|
||||
action(singleAvailableAddress)
|
||||
} else {
|
||||
val list = friend.getListOfSipAddressesAndPhoneNumbers(listener)
|
||||
Log.i(
|
||||
"$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog"
|
||||
)
|
||||
|
||||
coreContext.postOnMainThread {
|
||||
val numberOrAddressModel = NumberOrAddressPickerDialogModel(list)
|
||||
val dialog =
|
||||
DialogUtils.getNumberOrAddressPickerDialog(
|
||||
requireActivity(),
|
||||
numberOrAddressModel
|
||||
)
|
||||
numberOrAddressPickerDialog = dialog
|
||||
|
||||
numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event ->
|
||||
event.consume {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun action(address: Address) {
|
||||
Log.i("$TAG Calling [${address.asStringUriOnly()}]")
|
||||
coreContext.startAudioCall(address)
|
||||
|
||||
coreContext.postOnMainThread {
|
||||
try {
|
||||
findNavController().popBackStack()
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e("$TAG Can't go back: $ise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallOutgoingFragmentBinding
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
@UiThread
|
||||
class OutgoingCallFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Outgoing Call Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallOutgoingFragmentBinding
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallOutgoingFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callViewModel)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = callViewModel
|
||||
binding.numpadModel = callViewModel.numpadModel
|
||||
|
||||
callViewModel.isOutgoingEarlyMedia.observe(viewLifecycleOwner) { earlyMedia ->
|
||||
if (earlyMedia) {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
val call = core.calls.find {
|
||||
it.state == Call.State.OutgoingEarlyMedia
|
||||
}
|
||||
if (call != null && LinphoneUtils.isVideoEnabled(call)) {
|
||||
Log.i("$TAG Outgoing early-media call with video, setting preview surface")
|
||||
core.nativePreviewWindowId = binding.localPreviewVideoSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val numpadBottomSheetBehavior = BottomSheetBehavior.from(binding.callNumpad.root)
|
||||
numpadBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
numpadBottomSheetBehavior.skipCollapsed = true
|
||||
|
||||
callViewModel.showNumpadBottomSheetEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
numpadBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
(binding.root as? ViewGroup)?.doOnLayout {
|
||||
setupVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
cleanVideoPreview(binding.localPreviewVideoSurface)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import kotlin.getValue
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallTransferFragmentBinding
|
||||
import org.linphone.ui.call.adapter.CallsListAdapter
|
||||
import org.linphone.ui.call.model.CallModel
|
||||
import org.linphone.ui.call.viewmodel.CallsViewModel
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.ui.main.adapter.ConversationsContactsAndSuggestionsListAdapter
|
||||
import org.linphone.ui.main.history.viewmodel.StartCallViewModel
|
||||
import org.linphone.utils.ConfirmationDialogModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.DialogUtils
|
||||
import org.linphone.utils.RecyclerViewHeaderDecoration
|
||||
import org.linphone.utils.hideKeyboard
|
||||
import org.linphone.utils.setKeyboardInsetListener
|
||||
import org.linphone.utils.showKeyboard
|
||||
|
||||
@UiThread
|
||||
class TransferCallFragment : GenericCallFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Transfer Call Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: CallTransferFragmentBinding
|
||||
|
||||
private val viewModel: StartCallViewModel by navGraphViewModels(
|
||||
R.id.call_nav_graph
|
||||
)
|
||||
|
||||
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED || newState == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
viewModel.isNumpadVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
|
||||
}
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
private lateinit var callsViewModel: CallsViewModel
|
||||
|
||||
private lateinit var callsAdapter: CallsListAdapter
|
||||
|
||||
private lateinit var contactsAdapter: ConversationsContactsAndSuggestionsListAdapter
|
||||
|
||||
private var numberOrAddressPickerDialog: Dialog? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
callsAdapter = CallsListAdapter()
|
||||
contactsAdapter = ConversationsContactsAndSuggestionsListAdapter()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = CallTransferFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
callViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CurrentCallViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callViewModel)
|
||||
|
||||
callsViewModel = requireActivity().run {
|
||||
ViewModelProvider(this)[CallsViewModel::class.java]
|
||||
}
|
||||
observeToastEvents(callsViewModel)
|
||||
|
||||
binding.viewModel = viewModel
|
||||
binding.callsViewModel = callsViewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
binding.setBackClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
binding.setHideNumpadClickListener {
|
||||
viewModel.hideNumpad()
|
||||
}
|
||||
|
||||
binding.callsList.setHasFixedSize(true)
|
||||
binding.contactsAndSuggestionsList.setHasFixedSize(true)
|
||||
|
||||
binding.contactsAndSuggestionsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.callsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), contactsAdapter)
|
||||
binding.contactsAndSuggestionsList.addItemDecoration(headerItemDecoration)
|
||||
|
||||
callsAdapter.callClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
showConfirmAttendedTransferDialog(model)
|
||||
}
|
||||
}
|
||||
|
||||
contactsAdapter.onClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
showConfirmBlindTransferDialog(model.address, model.name)
|
||||
}
|
||||
}
|
||||
|
||||
callsViewModel.callsExceptCurrentOne.observe(viewLifecycleOwner) {
|
||||
Log.i("$TAG Calls list updated with [${it.size}] items")
|
||||
callsAdapter.submitList(it)
|
||||
|
||||
// Wait for adapter to have items before setting it in the RecyclerView,
|
||||
// otherwise scroll position isn't retained
|
||||
if (binding.callsList.adapter != callsAdapter) {
|
||||
binding.callsList.adapter = callsAdapter
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.modelsList.observe(
|
||||
viewLifecycleOwner
|
||||
) {
|
||||
Log.i("$TAG Contacts & suggestions list is ready with [${it.size}] items")
|
||||
val count = contactsAdapter.itemCount
|
||||
contactsAdapter.submitList(it)
|
||||
|
||||
// Wait for adapter to have items before setting it in the RecyclerView,
|
||||
// otherwise scroll position isn't retained
|
||||
if (binding.contactsAndSuggestionsList.adapter != contactsAdapter) {
|
||||
binding.contactsAndSuggestionsList.adapter = contactsAdapter
|
||||
}
|
||||
|
||||
if (count == 0) {
|
||||
(view.parent as? ViewGroup)?.doOnPreDraw {
|
||||
startPostponedEnterTransition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.searchFilter.observe(viewLifecycleOwner) { filter ->
|
||||
val trimmed = filter.trim()
|
||||
viewModel.applyFilter(trimmed)
|
||||
}
|
||||
|
||||
viewModel.removedCharacterAtCurrentPositionEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val selectionStart = binding.searchBar.selectionStart
|
||||
val selectionEnd = binding.searchBar.selectionEnd
|
||||
if (selectionStart > 0) {
|
||||
binding.searchBar.text =
|
||||
binding.searchBar.text?.delete(
|
||||
selectionStart - 1,
|
||||
selectionEnd
|
||||
)
|
||||
binding.searchBar.setSelection(selectionStart - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.appendDigitToSearchBarEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { digit ->
|
||||
val newValue = "${binding.searchBar.text}$digit"
|
||||
binding.searchBar.setText(newValue)
|
||||
binding.searchBar.setSelection(newValue.length)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.requestKeyboardVisibilityChangedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { show ->
|
||||
if (show) {
|
||||
// To automatically open keyboard
|
||||
binding.searchBar.showKeyboard()
|
||||
} else {
|
||||
binding.searchBar.requestFocus()
|
||||
binding.searchBar.hideKeyboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val bottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
|
||||
viewModel.isNumpadVisible.observe(viewLifecycleOwner) { visible ->
|
||||
if (visible) {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.initiateBlindTransferEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val address = pair.first
|
||||
val displayName = pair.second
|
||||
showConfirmBlindTransferDialog(address, displayName)
|
||||
}
|
||||
}
|
||||
|
||||
binding.root.setKeyboardInsetListener { keyboardVisible ->
|
||||
if (keyboardVisible) {
|
||||
viewModel.isNumpadVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
numberOrAddressPickerDialog?.dismiss()
|
||||
numberOrAddressPickerDialog = null
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
viewModel.title.value = getString(
|
||||
R.string.call_transfer_current_call_title,
|
||||
callViewModel.displayedName.value ?: callViewModel.displayedAddress.value
|
||||
)
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
if (corePreferences.automaticallyShowDialpad) {
|
||||
viewModel.isNumpadVisible.postValue(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showConfirmAttendedTransferDialog(callModel: CallModel) {
|
||||
val from = callViewModel.displayedName.value.orEmpty()
|
||||
val to = callModel.displayName.value.orEmpty()
|
||||
Log.i("$TAG Asking user confirmation before doing attended transfer of call with [$from] to [$to](${callModel.call.remoteAddress.asStringUriOnly()})")
|
||||
val label = AppUtils.getFormattedString(
|
||||
R.string.call_transfer_confirm_dialog_message,
|
||||
from,
|
||||
to
|
||||
)
|
||||
val model = ConfirmationDialogModel(label)
|
||||
val dialog = DialogUtils.getConfirmCallTransferCallDialog(
|
||||
requireActivity(),
|
||||
model
|
||||
)
|
||||
|
||||
model.dismissEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Attended transfer was cancelled by user")
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
model.confirmEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
coreContext.postOnCoreThread {
|
||||
val call = callModel.call
|
||||
Log.i(
|
||||
"$TAG Transferring (attended) call to [${call.remoteAddress.asStringUriOnly()}]"
|
||||
)
|
||||
callViewModel.attendedTransferCallTo(call)
|
||||
}
|
||||
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun showConfirmBlindTransferDialog(toAddress: Address, toDisplayName: String) {
|
||||
val from = callViewModel.displayedName.value.orEmpty()
|
||||
Log.i("$TAG Asking user confirmation before doing blind transfer of call with [$from] to [$toDisplayName](${toAddress.asStringUriOnly()})")
|
||||
val label = AppUtils.getFormattedString(
|
||||
R.string.call_transfer_confirm_dialog_message,
|
||||
from,
|
||||
toDisplayName
|
||||
)
|
||||
val model = ConfirmationDialogModel(label)
|
||||
val dialog = DialogUtils.getConfirmCallTransferCallDialog(
|
||||
requireActivity(),
|
||||
model
|
||||
)
|
||||
|
||||
model.dismissEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Blind transfer was cancelled by user")
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
model.confirmEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG Transferring (blind) call to [${toAddress.asStringUriOnly()}]")
|
||||
callViewModel.blindTransferCallTo(toAddress)
|
||||
}
|
||||
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.model
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.linphone.core.AudioDevice
|
||||
|
||||
data class AudioDeviceModel
|
||||
@WorkerThread
|
||||
constructor(
|
||||
val audioDevice: AudioDevice,
|
||||
val name: String,
|
||||
val type: AudioDevice.Type,
|
||||
val isCurrentlySelected: Boolean,
|
||||
val isEnabled: Boolean,
|
||||
private val onAudioDeviceSelected: (() -> Unit)? = null
|
||||
) {
|
||||
var dismissDialog: (() -> Unit)? = null
|
||||
|
||||
fun onClicked() {
|
||||
onAudioDeviceSelected?.invoke()
|
||||
dismissDialog?.invoke()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.model
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.MediaEncryption
|
||||
import org.linphone.core.StreamType
|
||||
import org.linphone.utils.AppUtils
|
||||
|
||||
class CallMediaEncryptionModel
|
||||
@WorkerThread
|
||||
constructor(
|
||||
private val showZrtpSasValidationDialog: () -> Unit
|
||||
) {
|
||||
val mediaEncryption = MutableLiveData<String>()
|
||||
|
||||
val isMediaEncryptionZrtp = MutableLiveData<Boolean>()
|
||||
val zrtpCipher = MutableLiveData<String>()
|
||||
val zrtpKeyAgreement = MutableLiveData<String>()
|
||||
val zrtpHash = MutableLiveData<String>()
|
||||
val zrtpAuthTag = MutableLiveData<String>()
|
||||
val zrtpAuthSas = MutableLiveData<String>()
|
||||
|
||||
@WorkerThread
|
||||
fun update(call: Call) {
|
||||
isMediaEncryptionZrtp.postValue(false)
|
||||
|
||||
val stats = call.getStats(StreamType.Audio)
|
||||
if (stats != null) {
|
||||
// ZRTP stats are only available when authentication token isn't null !
|
||||
if (call.currentParams.mediaEncryption == MediaEncryption.ZRTP && call.authenticationToken != null) {
|
||||
isMediaEncryptionZrtp.postValue(true)
|
||||
|
||||
if (stats.isZrtpKeyAgreementAlgoPostQuantum) {
|
||||
mediaEncryption.postValue(
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_stats_media_encryption,
|
||||
AppUtils.getString(
|
||||
R.string.call_stats_media_encryption_zrtp_post_quantum
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
mediaEncryption.postValue(
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_stats_media_encryption,
|
||||
call.currentParams.mediaEncryption.name
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
zrtpCipher.postValue(
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_stats_zrtp_cipher_algo,
|
||||
stats.zrtpCipherAlgo
|
||||
)
|
||||
)
|
||||
zrtpKeyAgreement.postValue(
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_stats_zrtp_key_agreement_algo,
|
||||
stats.zrtpKeyAgreementAlgo
|
||||
)
|
||||
)
|
||||
zrtpHash.postValue(
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_stats_zrtp_hash_algo,
|
||||
stats.zrtpHashAlgo
|
||||
)
|
||||
)
|
||||
zrtpAuthTag.postValue(
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_stats_zrtp_auth_tag_algo,
|
||||
stats.zrtpAuthTagAlgo
|
||||
)
|
||||
)
|
||||
zrtpAuthSas.postValue(
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_stats_zrtp_sas_algo,
|
||||
stats.zrtpSasAlgo
|
||||
)
|
||||
)
|
||||
} else {
|
||||
mediaEncryption.postValue(
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_stats_media_encryption,
|
||||
call.currentParams.mediaEncryption.name
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showSasValidationDialog() {
|
||||
showZrtpSasValidationDialog.invoke()
|
||||
}
|
||||
}
|
||||
106
app/src/main/java/org/linphone/ui/call/model/CallModel.kt
Normal file
106
app/src/main/java/org/linphone/ui/call/model/CallModel.kt
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.model
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.CallListenerStub
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.main.contacts.model.ContactAvatarModel
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
class CallModel
|
||||
@WorkerThread
|
||||
constructor(val call: Call) {
|
||||
companion object {
|
||||
private const val TAG = "[Call Model]"
|
||||
}
|
||||
|
||||
val id = call.callLog.callId
|
||||
|
||||
val displayName = MutableLiveData<String>()
|
||||
|
||||
val state = MutableLiveData<String>()
|
||||
|
||||
val isPaused = MutableLiveData<Boolean>()
|
||||
|
||||
val friend = coreContext.contactsManager.findContactByAddress(call.callLog.remoteAddress)
|
||||
|
||||
val contact = MutableLiveData<ContactAvatarModel>()
|
||||
|
||||
private val callListener = object : CallListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onStateChanged(call: Call, state: Call.State, message: String) {
|
||||
this@CallModel.state.postValue(LinphoneUtils.callStateToString(state))
|
||||
isPaused.postValue(LinphoneUtils.isCallPaused(state))
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
call.addListener(callListener)
|
||||
|
||||
val conferenceInfo = coreContext.core.findConferenceInformationFromUri(call.remoteAddress)
|
||||
val remoteAddress = call.callLog.remoteAddress
|
||||
val avatarModel = if (conferenceInfo != null) {
|
||||
coreContext.contactsManager.getContactAvatarModelForConferenceInfo(conferenceInfo)
|
||||
} else {
|
||||
coreContext.contactsManager.getContactAvatarModelForAddress(
|
||||
remoteAddress
|
||||
)
|
||||
}
|
||||
contact.postValue(avatarModel)
|
||||
displayName.postValue(
|
||||
avatarModel.friend.name ?: LinphoneUtils.getDisplayName(remoteAddress)
|
||||
)
|
||||
|
||||
state.postValue(LinphoneUtils.callStateToString(call.state))
|
||||
isPaused.postValue(LinphoneUtils.isCallPaused(call.state))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun destroy() {
|
||||
call.removeListener(callListener)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun togglePauseResume() {
|
||||
when (call.state) {
|
||||
Call.State.Paused -> {
|
||||
Log.i("$TAG Trying to resume call [${call.remoteAddress.asStringUriOnly()}]")
|
||||
call.resume()
|
||||
}
|
||||
else -> {
|
||||
Log.i("$TAG Trying to resume call [${call.remoteAddress.asStringUriOnly()}]")
|
||||
call.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun hangUp() {
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG Terminating call [${call.remoteAddress.asStringUriOnly()}]")
|
||||
coreContext.terminateCall(call)
|
||||
}
|
||||
}
|
||||
}
|
||||
168
app/src/main/java/org/linphone/ui/call/model/CallStatsModel.kt
Normal file
168
app/src/main/java/org/linphone/ui/call/model/CallStatsModel.kt
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.model
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlin.math.roundToInt
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.CallStats
|
||||
import org.linphone.core.MediaDirection
|
||||
import org.linphone.core.StreamType
|
||||
import org.linphone.utils.AppUtils
|
||||
|
||||
class CallStatsModel
|
||||
@WorkerThread
|
||||
constructor() {
|
||||
val audioCodec = MutableLiveData<String>()
|
||||
val audioBandwidth = MutableLiveData<String>()
|
||||
val lossRate = MutableLiveData<String>()
|
||||
val jitterBuffer = MutableLiveData<String>()
|
||||
|
||||
val isVideoEnabled = MutableLiveData<Boolean>()
|
||||
val videoCodec = MutableLiveData<String>()
|
||||
val videoBandwidth = MutableLiveData<String>()
|
||||
val videoLossRate = MutableLiveData<String>()
|
||||
val videoResolution = MutableLiveData<String>()
|
||||
val videoFps = MutableLiveData<String>()
|
||||
|
||||
val fecEnabled = MutableLiveData<Boolean>()
|
||||
val lostPackets = MutableLiveData<String>()
|
||||
val repairedPackets = MutableLiveData<String>()
|
||||
val fecBandwidth = MutableLiveData<String>()
|
||||
|
||||
@WorkerThread
|
||||
fun update(call: Call, stats: CallStats?) {
|
||||
stats ?: return
|
||||
|
||||
val videoEnabled = call.currentParams.isVideoEnabled
|
||||
val remoteParamsVideoDirection = call.remoteParams?.videoDirection
|
||||
val remoteSendsVideo = remoteParamsVideoDirection == MediaDirection.SendRecv || remoteParamsVideoDirection == MediaDirection.SendOnly
|
||||
val localParamsVideoDirection = call.params.videoDirection
|
||||
val localSendsVideo = localParamsVideoDirection == MediaDirection.SendRecv || localParamsVideoDirection == MediaDirection.SendOnly
|
||||
val showVideoStats = videoEnabled && (remoteSendsVideo || localSendsVideo)
|
||||
isVideoEnabled.postValue(showVideoStats)
|
||||
|
||||
val isFecEnabled = call.currentParams.isFecEnabled
|
||||
fecEnabled.postValue(showVideoStats && isFecEnabled)
|
||||
|
||||
when (stats.type) {
|
||||
StreamType.Audio -> {
|
||||
val payloadType = call.currentParams.usedAudioPayloadType
|
||||
val clockRate = (payloadType?.clockRate ?: 0) / 1000
|
||||
val codecLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_codec_label,
|
||||
"${payloadType?.mimeType}/$clockRate kHz"
|
||||
)
|
||||
audioCodec.postValue(codecLabel)
|
||||
|
||||
val uploadBandwidth = stats.uploadBandwidth.roundToInt()
|
||||
val downloadBandwidth = stats.downloadBandwidth.roundToInt()
|
||||
val bandwidthLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_bandwidth_label,
|
||||
"↑ $uploadBandwidth kbits/s ↓ $downloadBandwidth kbits/s"
|
||||
)
|
||||
audioBandwidth.postValue(bandwidthLabel)
|
||||
|
||||
val uploadLoss = stats.senderLossRate.roundToInt()
|
||||
val downloadLoss = stats.receiverLossRate.roundToInt()
|
||||
val lossRateLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_loss_rate_label,
|
||||
"↑ $uploadLoss% ↓ $downloadLoss%"
|
||||
)
|
||||
lossRate.postValue(lossRateLabel)
|
||||
|
||||
val jitterBufferSize = stats.jitterBufferSizeMs.roundToInt()
|
||||
val jitterBufferLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_jitter_buffer_label,
|
||||
"$jitterBufferSize ms"
|
||||
)
|
||||
jitterBuffer.postValue(jitterBufferLabel)
|
||||
}
|
||||
StreamType.Video -> {
|
||||
val payloadType = call.currentParams.usedVideoPayloadType
|
||||
val clockRate = (payloadType?.clockRate ?: 0) / 1000
|
||||
val codecLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_codec_label,
|
||||
"${payloadType?.mimeType}/$clockRate kHz"
|
||||
)
|
||||
videoCodec.postValue(codecLabel)
|
||||
|
||||
val uploadBandwidth = stats.uploadBandwidth.roundToInt()
|
||||
val downloadBandwidth = stats.downloadBandwidth.roundToInt()
|
||||
val bandwidthLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_bandwidth_label,
|
||||
"↑ $uploadBandwidth kbits/s ↓ $downloadBandwidth kbits/s"
|
||||
)
|
||||
videoBandwidth.postValue(bandwidthLabel)
|
||||
|
||||
val uploadLoss = stats.senderLossRate.roundToInt()
|
||||
val downloadLoss = stats.receiverLossRate.roundToInt()
|
||||
val lossRateLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_loss_rate_label,
|
||||
"↑ $uploadLoss% ↓ $downloadLoss%"
|
||||
)
|
||||
videoLossRate.postValue(lossRateLabel)
|
||||
|
||||
val sentResolution = call.currentParams.sentVideoDefinition?.name
|
||||
val receivedResolution = call.currentParams.receivedVideoDefinition?.name
|
||||
val resolutionLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_resolution_label,
|
||||
"↑ $sentResolution ↓ $receivedResolution"
|
||||
)
|
||||
videoResolution.postValue(resolutionLabel)
|
||||
|
||||
val sentFps = call.currentParams.sentFramerate.roundToInt()
|
||||
val receivedFps = call.currentParams.receivedFramerate.roundToInt()
|
||||
val fpsLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_fps_label,
|
||||
"↑ $sentFps ↓ $receivedFps"
|
||||
)
|
||||
videoFps.postValue(fpsLabel)
|
||||
|
||||
if (isFecEnabled) {
|
||||
val lostPacketsValue = stats.fecCumulativeLostPacketsNumber
|
||||
val lostPacketsLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_fec_lost_packets_label,
|
||||
lostPacketsValue
|
||||
)
|
||||
lostPackets.postValue(lostPacketsLabel)
|
||||
|
||||
val repairedPacketsValue = stats.fecRepairedPacketsNumber
|
||||
val repairedPacketsLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_fec_repaired_packets_label,
|
||||
repairedPacketsValue
|
||||
)
|
||||
repairedPackets.postValue(repairedPacketsLabel)
|
||||
|
||||
val fecUploadBandwidth = stats.fecUploadBandwidth.roundToInt()
|
||||
val fecDownloadBandwidth = stats.fecDownloadBandwidth.roundToInt()
|
||||
val fecBandwidthLabel = AppUtils.getFormattedString(
|
||||
R.string.call_stats_fec_lost_bandwidth_label,
|
||||
"↑ $fecUploadBandwidth kbits/s ↓ $fecDownloadBandwidth kbits/s"
|
||||
)
|
||||
fecBandwidth.postValue(fecBandwidthLabel)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.model
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.utils.Event
|
||||
|
||||
class ZrtpAlertDialogModel
|
||||
@UiThread
|
||||
constructor(val allowTryAgain: Boolean) : GenericViewModel() {
|
||||
val tryAgainEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val hangUpEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
@UiThread
|
||||
fun tryAgain() {
|
||||
tryAgainEvent.value = Event(true)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun hangUp() {
|
||||
hangUpEvent.value = Event(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.model
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.utils.Event
|
||||
|
||||
class ZrtpSasConfirmationDialogModel
|
||||
@UiThread
|
||||
constructor(
|
||||
authTokenToRead: String,
|
||||
authTokensToListen: List<String>,
|
||||
val cacheMismatch: Boolean
|
||||
) : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[ZRTP SAS Confirmation Dialog]"
|
||||
}
|
||||
|
||||
val localToken = MutableLiveData<String>()
|
||||
val letters1 = MutableLiveData<String>()
|
||||
val letters2 = MutableLiveData<String>()
|
||||
val letters3 = MutableLiveData<String>()
|
||||
val letters4 = MutableLiveData<String>()
|
||||
|
||||
val authTokenClickedEvent = MutableLiveData<Event<String>>()
|
||||
|
||||
val skipEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
init {
|
||||
localToken.value = authTokenToRead
|
||||
letters1.value = authTokensToListen[0]
|
||||
letters2.value = authTokensToListen[1]
|
||||
letters3.value = authTokensToListen[2]
|
||||
letters4.value = authTokensToListen[3]
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun skip() {
|
||||
skipEvent.value = Event(true)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun notFound() {
|
||||
Log.e("$TAG User clicked on 'Not Found' button!")
|
||||
authTokenClickedEvent.value = Event("")
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun lettersClicked(letters: MutableLiveData<String>) {
|
||||
val token = letters.value.orEmpty()
|
||||
Log.i("$TAG User clicked on [$token] letters")
|
||||
authTokenClickedEvent.value = Event(token)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue