mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-11 20:43:34 +01:00
Compare commits
198 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34104355cb | ||
|
|
143c1255d8 | ||
|
|
a9d0e5ac81 | ||
|
|
c8b3ee51ed | ||
|
|
c964067139 | ||
|
|
539f590c4c | ||
|
|
71dddd4a1b | ||
|
|
792f5dd7b5 | ||
|
|
a3af49d92a | ||
|
|
4289b43a81 | ||
|
|
7f55623acf | ||
|
|
52060b65be | ||
|
|
fd826749e4 | ||
|
|
627e15c3dd | ||
|
|
90f6890180 | ||
|
|
61eb397d2b | ||
|
|
3a5e5364c7 | ||
|
|
25779d04a6 | ||
|
|
242900e87f | ||
|
|
05f07d1788 | ||
|
|
f961f4ccac | ||
|
|
145377b05f | ||
|
|
bc88887195 | ||
|
|
5362b1c21c | ||
|
|
0cfd3265ba | ||
|
|
1099128513 | ||
|
|
ad50c81a6b | ||
|
|
0817f113c6 | ||
|
|
4a9a07a9ef | ||
|
|
61f50cfe60 | ||
|
|
92888778c2 | ||
|
|
987f9b9dba | ||
|
|
97d95f37cc | ||
|
|
836cd04564 | ||
|
|
c26f54161d | ||
|
|
b540009ce6 | ||
|
|
e58e209950 | ||
|
|
4597a23104 | ||
|
|
3aacf4bcd2 | ||
|
|
6e6b663fac | ||
|
|
6dad7eafcf | ||
|
|
5a38143987 | ||
|
|
4d6d31d624 | ||
|
|
938c82be3f | ||
|
|
dc2e249566 | ||
|
|
bb8fdcabcb | ||
|
|
6cf4dbc78c | ||
|
|
8cd0ac5451 | ||
|
|
33745f0b0c | ||
|
|
29ffed219f | ||
|
|
9629d0f715 | ||
|
|
01651984d7 | ||
|
|
c7c15250ca | ||
|
|
36b96eafcc | ||
|
|
94f930ee22 | ||
|
|
3f740d2904 | ||
|
|
f8529adfcf | ||
|
|
77ccbdd322 | ||
|
|
f2846efd2c | ||
|
|
131f9c4bc9 | ||
|
|
d7c06fff50 | ||
|
|
09a22d9dc4 | ||
|
|
0fbab04253 | ||
|
|
c963e99dca | ||
|
|
7a555d127f | ||
|
|
866408f673 | ||
|
|
a7e5ab1a6a | ||
|
|
14d16d61e6 | ||
|
|
b988e4a813 | ||
|
|
ae33c8db1b | ||
|
|
9f1c5ac6bb | ||
|
|
448e7d0739 | ||
|
|
8971ff9057 | ||
|
|
2d6b16b2ce | ||
|
|
44ab1643fa | ||
|
|
a64bffd83a | ||
|
|
534c5c3c64 | ||
|
|
99d3f9918f | ||
|
|
aaebf029db | ||
|
|
e2c2ace0e3 | ||
|
|
c76002663f | ||
|
|
c5317370c8 | ||
|
|
4b09f4a654 | ||
|
|
0c57113d8e | ||
|
|
d7dd77a5af | ||
|
|
5c5b88ebcc | ||
|
|
8df0248d4f | ||
|
|
58e48fdf14 | ||
|
|
407fc56218 | ||
|
|
398527d3f1 | ||
|
|
59745a695c | ||
|
|
2aaeda6ca8 | ||
|
|
5c78de2f46 | ||
|
|
93efc21452 | ||
|
|
50ad005e7c | ||
|
|
71c3bcdd29 | ||
|
|
e5bf04a407 | ||
|
|
fe8b2cb761 | ||
|
|
7c37f929a5 | ||
|
|
1b82d10b39 | ||
|
|
6b6e2490e7 | ||
|
|
ebee54cf92 | ||
|
|
b7acfb0dcc | ||
|
|
65dab45582 | ||
|
|
43086f9582 | ||
|
|
ed1aa74aff | ||
|
|
3ba128793a | ||
|
|
ffbbdc1576 | ||
|
|
6ff55cfff7 | ||
|
|
e9f1f781e1 | ||
|
|
6da36fe098 | ||
|
|
acb6510312 | ||
|
|
13248506c5 | ||
|
|
fb9740f5c3 | ||
|
|
61bf788f52 | ||
|
|
fd116e0178 | ||
|
|
661d981231 | ||
|
|
6a701591af | ||
|
|
02431c6ef4 | ||
|
|
802b179880 | ||
|
|
6c2104b84b | ||
|
|
a3f432dc88 | ||
|
|
7f4caedf40 | ||
|
|
eebd06f0d8 | ||
|
|
0ea66b6bb0 | ||
|
|
084cdd7200 | ||
|
|
2eff9e0230 | ||
|
|
6615bc4a2a | ||
|
|
387f18be98 | ||
|
|
3b5a3eccfe | ||
|
|
36083a8bd9 | ||
|
|
7e16825bf4 | ||
|
|
9ba2724d0a | ||
|
|
7866851f5d | ||
|
|
c938035ec1 | ||
|
|
965fdc5e9b | ||
|
|
eab932b4a0 | ||
|
|
27a24262c8 | ||
|
|
31584de225 | ||
|
|
6b2f90019a | ||
|
|
208147db9e | ||
|
|
e4f70fa4fe | ||
|
|
4d09abd0d3 | ||
|
|
1304f4dc39 | ||
|
|
ac027d9267 | ||
|
|
0b6d343616 | ||
|
|
92e8f125f9 | ||
|
|
bef15482af | ||
|
|
93d78b3b2e | ||
|
|
d38b7deeeb | ||
|
|
b656e1dd0a | ||
|
|
f1cec895b9 | ||
|
|
9bf6922d97 | ||
|
|
41fc4096e4 | ||
|
|
e46564cb7e | ||
|
|
77751c1d28 | ||
|
|
054a1e4017 | ||
|
|
da27d74111 | ||
|
|
970278228d | ||
|
|
ee3f2d62cf | ||
|
|
ec6d5031cf | ||
|
|
80f338c3af | ||
|
|
268c9a1c26 | ||
|
|
e8e01b5965 | ||
|
|
23a042b667 | ||
|
|
c2c1537858 | ||
|
|
65d5f4c426 | ||
|
|
a1eb33b1f6 | ||
|
|
9eadd92d05 | ||
|
|
b0e1294584 | ||
|
|
f1fd29a477 | ||
|
|
fc9a6b98d1 | ||
|
|
305f6c610c | ||
|
|
66f4732db5 | ||
|
|
ccdfa546b4 | ||
|
|
855e194baa | ||
|
|
e0c06615fb | ||
|
|
03a4809866 | ||
|
|
0aa7bd6a4e | ||
|
|
78b530f8b8 | ||
|
|
ace47c61b1 | ||
|
|
29796f51d7 | ||
|
|
4d2ce7a2be | ||
|
|
8dc45263cd | ||
|
|
d8d95d8efe | ||
|
|
f081591354 | ||
|
|
35e96fecdb | ||
|
|
841fbfa7ee | ||
|
|
89397ae7cc | ||
|
|
6c57c2ac2a | ||
|
|
40663eb52f | ||
|
|
90c9cc17b9 | ||
|
|
125c4f43cf | ||
|
|
634e4abcc1 | ||
|
|
a5431330d1 | ||
|
|
30fc6d94c5 | ||
|
|
694d8f1984 | ||
|
|
bff8fc8230 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ jni/libspeex/.deps/
|
||||
pkcs11.password
|
||||
dev.keystore
|
||||
maps.key
|
||||
local/
|
||||
6
.idea/copyright/Signal.xml
generated
Normal file
6
.idea/copyright/Signal.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Copyright &#36;today.year Signal Messenger, LLC SPDX-License-Identifier: AGPL-3.0-only" />
|
||||
<option name="myName" value="Signal" />
|
||||
</copyright>
|
||||
</component>
|
||||
7
.idea/copyright/profiles_settings.xml
generated
Normal file
7
.idea/copyright/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings>
|
||||
<module2copyright>
|
||||
<element module="All" copyright="Signal" />
|
||||
</module2copyright>
|
||||
</settings>
|
||||
</component>
|
||||
9
.idea/fileTemplates/internal/AnnotationType.java
generated
Normal file
9
.idea/fileTemplates/internal/AnnotationType.java
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright ${YEAR} Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
|
||||
#parse("File Header.java")
|
||||
public @interface ${NAME} {
|
||||
}
|
||||
9
.idea/fileTemplates/internal/Class.java
generated
Normal file
9
.idea/fileTemplates/internal/Class.java
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright ${YEAR} Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
|
||||
#parse("File Header.java")
|
||||
public class ${NAME} {
|
||||
}
|
||||
9
.idea/fileTemplates/internal/Enum.java
generated
Normal file
9
.idea/fileTemplates/internal/Enum.java
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright ${YEAR} Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
|
||||
#parse("File Header.java")
|
||||
public enum ${NAME} {
|
||||
}
|
||||
9
.idea/fileTemplates/internal/Interface.java
generated
Normal file
9
.idea/fileTemplates/internal/Interface.java
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright ${YEAR} Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
|
||||
#parse("File Header.java")
|
||||
public interface ${NAME} {
|
||||
}
|
||||
11
.idea/fileTemplates/internal/Kotlin Class.kt
generated
Normal file
11
.idea/fileTemplates/internal/Kotlin Class.kt
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright ${YEAR} Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
|
||||
|
||||
#end
|
||||
#parse("File Header.java")
|
||||
class ${NAME} {
|
||||
}
|
||||
11
.idea/fileTemplates/internal/Kotlin Enum.kt
generated
Normal file
11
.idea/fileTemplates/internal/Kotlin Enum.kt
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright ${YEAR} Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
|
||||
|
||||
#end
|
||||
#parse("File Header.java")
|
||||
enum class ${NAME} {
|
||||
}
|
||||
9
.idea/fileTemplates/internal/Kotlin File.kt
generated
Normal file
9
.idea/fileTemplates/internal/Kotlin File.kt
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright ${YEAR} Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
|
||||
|
||||
#end
|
||||
#parse("File Header.java")
|
||||
11
.idea/fileTemplates/internal/Kotlin Interface.kt
generated
Normal file
11
.idea/fileTemplates/internal/Kotlin Interface.kt
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright ${YEAR} Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
|
||||
|
||||
#end
|
||||
#parse("File Header.java")
|
||||
interface ${NAME} {
|
||||
}
|
||||
152
LICENSE
152
LICENSE
@@ -1,23 +1,21 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -619,3 +617,45 @@ Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero 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 Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -54,8 +54,8 @@ The form and manner of this distribution makes it eligible for export under the
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2013-2022 Signal
|
||||
Copyright 2013-2023 Signal
|
||||
|
||||
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
|
||||
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
Google Play and the Google Play logo are trademarks of Google LLC.
|
||||
|
||||
@@ -4,6 +4,7 @@ plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'com.google.protobuf'
|
||||
id 'com.google.android.gms.oss-licenses-plugin'
|
||||
id 'androidx.navigation.safeargs'
|
||||
id 'org.jlleitschuh.gradle.ktlint'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
@@ -46,8 +47,8 @@ ktlint {
|
||||
version = "0.47.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1256
|
||||
def canonicalVersionName = "6.19.8"
|
||||
def canonicalVersionCode = 1268
|
||||
def canonicalVersionName = "6.22.1"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -182,10 +183,10 @@ android {
|
||||
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\""
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\", \"Development\"}"
|
||||
@@ -196,12 +197,12 @@ android {
|
||||
buildConfigField "String[]", "SIGNAL_STORAGE_IPS", storage_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDS_IPS", cds_ips
|
||||
buildConfigField "String[]", "SIGNAL_KBS_IPS", kbs_ips
|
||||
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
|
||||
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
|
||||
buildConfigField "String", "SVR2_MRENCLAVE", "\"dc9fd472a5a9c871a3c7f76f1af60aa9c1f314abf2e8d1e0c4ba25c8aaa2848c\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
|
||||
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
|
||||
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
|
||||
@@ -210,6 +211,7 @@ android {
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\""
|
||||
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
|
||||
@@ -375,7 +377,6 @@ android {
|
||||
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
|
||||
@@ -386,6 +387,7 @@ android {
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
|
||||
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
|
||||
@@ -466,6 +468,7 @@ dependencies {
|
||||
implementation libs.androidx.legacy.preference
|
||||
implementation libs.androidx.gridlayout
|
||||
implementation libs.androidx.exifinterface
|
||||
implementation libs.androidx.compose.rxjava3
|
||||
implementation libs.androidx.constraintlayout
|
||||
implementation libs.androidx.multidex
|
||||
implementation libs.androidx.navigation.fragment.ktx
|
||||
@@ -523,12 +526,6 @@ dependencies {
|
||||
exclude group: 'com.google.protobuf'
|
||||
}
|
||||
|
||||
implementation(libs.signal.argon2) {
|
||||
artifact {
|
||||
type = "aar"
|
||||
}
|
||||
}
|
||||
|
||||
implementation libs.signal.ringrtc
|
||||
|
||||
implementation libs.leolin.shortcutbadger
|
||||
@@ -566,6 +563,7 @@ dependencies {
|
||||
}
|
||||
implementation libs.dnsjava
|
||||
implementation libs.kotlinx.collections.immutable
|
||||
implementation libs.accompanist.permissions
|
||||
|
||||
spinnerImplementation project(":spinner")
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenACall_whenISetTimestamp_thenIExpectUpdatedTimestamp() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
@@ -30,8 +29,8 @@ class CallTableTest {
|
||||
now
|
||||
)
|
||||
|
||||
SignalDatabase.calls.setTimestamp(callId, conversationId, -1L)
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
SignalDatabase.calls.setTimestamp(callId, harness.others[0], -1L)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(-1L, call?.timestamp)
|
||||
|
||||
@@ -43,7 +42,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenPreExistingEvent_whenIDeleteGroupCall_thenIMarkDeletedAndSetTimestamp() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
@@ -52,10 +50,10 @@ class CallTableTest {
|
||||
now
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
SignalDatabase.calls.deleteGroupCall(call!!)
|
||||
|
||||
val deletedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val deletedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
|
||||
|
||||
assertEquals(CallTable.Event.DELETE, deletedCall?.event)
|
||||
@@ -66,7 +64,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPreExistingEvent_whenIDeleteGroupCall_thenIInsertAndMarkCallDeleted() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -74,7 +71,7 @@ class CallTableTest {
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
|
||||
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
|
||||
@@ -87,7 +84,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenIInsertAcceptedOutgoingGroupCall_thenIExpectLocalRingerAndOutgoingRing() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -95,7 +91,7 @@ class CallTableTest {
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
|
||||
assertEquals(harness.self.id, call?.ringerRecipient)
|
||||
@@ -105,7 +101,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenIInsertAcceptedIncomingGroupCall_thenIExpectJoined() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -113,7 +108,7 @@ class CallTableTest {
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.JOINED, call?.event)
|
||||
assertNull(call?.ringerRecipient)
|
||||
@@ -123,7 +118,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenARingingCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = harness.others[0],
|
||||
@@ -132,7 +126,7 @@ class CallTableTest {
|
||||
ringState = CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
|
||||
@@ -140,14 +134,13 @@ class CallTableTest {
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAMissedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = harness.others[0],
|
||||
@@ -156,7 +149,7 @@ class CallTableTest {
|
||||
ringState = CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
|
||||
@@ -164,14 +157,13 @@ class CallTableTest {
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADeclinedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = harness.others[0],
|
||||
@@ -180,7 +172,7 @@ class CallTableTest {
|
||||
ringState = CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
|
||||
@@ -188,7 +180,7 @@ class CallTableTest {
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@@ -196,7 +188,6 @@ class CallTableTest {
|
||||
fun givenAGenericGroupCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -206,7 +197,7 @@ class CallTableTest {
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
|
||||
@@ -214,7 +205,7 @@ class CallTableTest {
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertEquals(CallTable.Event.JOINED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@@ -222,7 +213,6 @@ class CallTableTest {
|
||||
fun givenNoPriorCallEvent_whenIReceiveAGroupCallUpdateMessage_thenIExpectAGenericGroupCall() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -232,7 +222,7 @@ class CallTableTest {
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
}
|
||||
@@ -242,7 +232,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -252,7 +241,7 @@ class CallTableTest {
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
SignalDatabase.calls.getCallById(callId, conversationId).let {
|
||||
SignalDatabase.calls.getCallById(callId, harness.others[0]).let {
|
||||
assertNotNull(it)
|
||||
assertEquals(now, it?.timestamp)
|
||||
}
|
||||
@@ -266,7 +255,7 @@ class CallTableTest {
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
assertEquals(1L, call?.timestamp)
|
||||
@@ -275,7 +264,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenADeletedCallEvent_whenIReceiveARingUpdate_thenIIgnoreTheRingUpdate() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
|
||||
callId = callId,
|
||||
recipientId = harness.others[0],
|
||||
@@ -291,7 +279,7 @@ class CallTableTest {
|
||||
ringState = CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DELETE, call?.event)
|
||||
}
|
||||
@@ -301,7 +289,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -319,7 +306,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -330,7 +317,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -346,7 +332,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -357,7 +343,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -375,7 +360,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -386,7 +371,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -412,7 +396,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -423,7 +407,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -439,7 +422,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_LOCALLY
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
}
|
||||
@@ -449,7 +432,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -465,7 +447,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
}
|
||||
@@ -475,7 +457,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -501,7 +482,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_LOCALLY
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
}
|
||||
@@ -511,7 +492,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -537,7 +517,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
}
|
||||
@@ -547,7 +527,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -565,7 +544,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
}
|
||||
@@ -574,7 +553,6 @@ class CallTableTest {
|
||||
fun givenARingingCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
@@ -592,7 +570,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
}
|
||||
@@ -601,7 +579,6 @@ class CallTableTest {
|
||||
fun givenAMissedCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
@@ -619,7 +596,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
}
|
||||
@@ -628,7 +605,6 @@ class CallTableTest {
|
||||
fun givenAnOutgoingRingCallEvent_whenRingDeclinedOnAnotherDevice_thenIDoNotChangeState() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
@@ -645,7 +621,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
|
||||
}
|
||||
@@ -653,7 +629,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingRequested_thenICreateAnEventInTheRingingStateAndSetRinger() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -662,7 +637,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -672,7 +647,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingExpired_thenICreateAnEventInTheMissedStateAndSetRinger() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -681,7 +655,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -691,7 +665,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingCancelledByRinger_thenICreateAnEventInTheMissedStateAndSetRinger() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -700,7 +673,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.CANCELLED_BY_RINGER
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -710,7 +683,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyLocally_thenICreateAnEventInTheMissedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -719,7 +691,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_LOCALLY
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
@@ -728,7 +700,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenICreateAnEventInTheMissedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -737,7 +708,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
@@ -746,7 +717,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingAcceptedOnAnotherDevice_thenICreateAnEventInTheAcceptedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -755,7 +725,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
@@ -764,7 +734,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingDeclinedOnAnotherDevice_thenICreateAnEventInTheDeclinedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -773,7 +742,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
|
||||
@@ -111,10 +111,11 @@ class DatabaseConsistencyTest {
|
||||
.split("\n")
|
||||
.map { it.trim() }
|
||||
.joinToString(separator = " ")
|
||||
.replace(Regex.fromLiteral(" ,"), ",")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.replace(Regex.fromLiteral("( "), "(")
|
||||
.replace(Regex.fromLiteral(" )"), ")")
|
||||
.replace(Regex("CREATE TABLE \"([a-z]+)\""), "CREATE TABLE $1") // for some reason SQLite will wrap table names in quotes for upgraded tables. This unwraps them.
|
||||
.replace(Regex("CREATE TABLE \"([a-zA-Z_]+)\""), "CREATE TABLE $1") // for some reason SQLite will wrap table names in quotes for upgraded tables. This unwraps them.
|
||||
}
|
||||
|
||||
private class InMemoryTestHelper(private val application: Application) : SQLiteOpenHelper(application, null, null, 1) {
|
||||
|
||||
@@ -122,6 +122,37 @@ class GroupTableTest {
|
||||
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroup_whenIRemapRecipientsThatHaveAConflict_thenIExpectDeletion() {
|
||||
val v2Group = insertPushGroupWithSelfAndOthers(
|
||||
listOf(
|
||||
harness.others[0],
|
||||
harness.others[1]
|
||||
)
|
||||
)
|
||||
|
||||
insertThread(v2Group)
|
||||
|
||||
groupTable.remapRecipient(harness.others[0], harness.others[1])
|
||||
|
||||
val groupRecord = groupTable.getGroup(v2Group).get()
|
||||
|
||||
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroup_whenIRemapRecipients_thenIExpectRemap() {
|
||||
val v2Group = insertPushGroup()
|
||||
insertThread(v2Group)
|
||||
|
||||
val newId = harness.others[1]
|
||||
groupTable.remapRecipient(harness.others[0], newId)
|
||||
|
||||
val groupRecord = groupTable.getGroup(v2Group).get()
|
||||
|
||||
assertEquals(setOf(harness.self.id, newId), groupRecord.members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupAndMember_whenIIsCurrentMember_thenIExpectTrue() {
|
||||
val v2Group = insertPushGroup()
|
||||
@@ -280,6 +311,31 @@ class GroupTableTest {
|
||||
.setRevision(0)
|
||||
.build()
|
||||
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)!!
|
||||
}
|
||||
|
||||
private fun insertPushGroupWithSelfAndOthers(others: List<RecipientId>): GroupId {
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
|
||||
val selfMember: DecryptedMember = DecryptedMember.newBuilder()
|
||||
.setUuid(harness.self.requireServiceId().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
|
||||
val otherMembers: List<DecryptedMember> = others.map { id ->
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(Recipient.resolved(id).requireServiceId().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
}
|
||||
|
||||
val decryptedGroupState = DecryptedGroup.newBuilder()
|
||||
.addAllMembers(listOf(selfMember) + otherMembers)
|
||||
.setRevision(0)
|
||||
.build()
|
||||
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)!!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +180,45 @@ class SQLiteDatabaseTest {
|
||||
assertTrue(hasRun1.get())
|
||||
assertTrue(hasRun2.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsAfterMainTransactionInNestedTransaction() {
|
||||
val hasRun1 = AtomicBoolean(false)
|
||||
val hasRun2 = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
hasRun1.set(true)
|
||||
}
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
assertTrue(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
hasRun2.set(true)
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.endTransaction()
|
||||
db.setTransactionSuccessful()
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.endTransaction()
|
||||
|
||||
assertTrue(hasRun1.get())
|
||||
assertTrue(hasRun2.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +82,12 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
emptyArray(),
|
||||
emptyList(),
|
||||
Optional.of(SignalServiceNetworkAccess.DNS),
|
||||
Optional.empty(),
|
||||
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS)
|
||||
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
|
||||
Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS)
|
||||
)
|
||||
|
||||
serviceNetworkAccessMock = mock {
|
||||
|
||||
@@ -5,9 +5,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.signal.core.util.Hex;
|
||||
import org.whispersystems.signalservice.api.kbs.HashedPin;
|
||||
import org.signal.libsignal.svr2.PinHash;
|
||||
import org.whispersystems.signalservice.api.kbs.KbsData;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -24,16 +25,16 @@ public final class PinHashing_hashPin_Test {
|
||||
byte[] backupId = Hex.fromStringCondensed("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
|
||||
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"));
|
||||
|
||||
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
|
||||
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
|
||||
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
|
||||
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
|
||||
|
||||
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("ab7e8499d21f80a6600b3b9ee349ac6d72c07e3359fe885a934ba7aa844429f8"), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("3f33ce58eb25b40436592a30eae2a8fabab1899095f4e2fba6e2d0dc43b4a2d9cac5a3931748522393951e0e54dec769"), kbsData.getCipherText());
|
||||
assertEquals(masterKey, kbsData.getMasterKey());
|
||||
|
||||
String localPinHash = PinHashing.localPinHash(pin);
|
||||
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
|
||||
String localPinHash = PinHashUtil.localPinHash(pin);
|
||||
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -42,16 +43,16 @@ public final class PinHashing_hashPin_Test {
|
||||
byte[] backupId = Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f");
|
||||
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("88a787415a2ecd79da0d1016a82a27c5c695c9a19b88b0aa1d35683280aa9a67"));
|
||||
|
||||
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
|
||||
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
|
||||
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
|
||||
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
|
||||
|
||||
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("301d9dd1e96f20ce51083f67d3298fd37b97525de8324d5e12ed2d407d3d927b"), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("9d9b05402ea39c17ff1c9298c8a0e86784a352aa02a74943bf8bcf07ec0f4b574a5b786ad0182c8d308d9eb06538b8c9"), kbsData.getCipherText());
|
||||
assertEquals(masterKey, kbsData.getMasterKey());
|
||||
|
||||
String localPinHash = PinHashing.localPinHash(pin);
|
||||
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
|
||||
String localPinHash = PinHashUtil.localPinHash(pin);
|
||||
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,18 +61,18 @@ public final class PinHashing_hashPin_Test {
|
||||
byte[] backupId = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8");
|
||||
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("9571f3fde1e58588ba49bcf82be1b301ca3859a6f59076f79a8f47181ef952bf"));
|
||||
|
||||
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
|
||||
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
|
||||
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
|
||||
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
|
||||
|
||||
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("ab645acdccc1652a48a34b2ac6926340ff35c03034013f68760f20013f028dd8"), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("11c0ba1834db15e47c172f6c987c64bd4cfc69c6047dd67a022afeec0165a10943f204d5b8f37b3cb7bab21c6dfc39c8"), kbsData.getCipherText());
|
||||
assertEquals(masterKey, kbsData.getMasterKey());
|
||||
|
||||
assertEquals("577939bccb2b6638c39222d5a97998a867c5e154e30b82cc120f2dd07a3de987", kbsData.getMasterKey().deriveRegistrationLock());
|
||||
|
||||
String localPinHash = PinHashing.localPinHash(pin);
|
||||
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
|
||||
String localPinHash = PinHashUtil.localPinHash(pin);
|
||||
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -80,18 +81,17 @@ public final class PinHashing_hashPin_Test {
|
||||
byte[] backupId = Hex.fromStringCondensed("717dc111a98423a57196512606822fca646c653facd037c10728f14ba0be2ab3");
|
||||
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("0432d735b32f66d0e3a70d4f9cc821a8529521a4937d26b987715d8eff4e4c54"));
|
||||
|
||||
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
|
||||
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
|
||||
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
|
||||
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
|
||||
|
||||
|
||||
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("d2fedabd0d4c17a371491c9722578843a26be3b4923e28d452ab2fc5491e794b"), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("877ef871ef1fc668401c717ef21aa12e8523579fb1ff4474b76f28c2293537c80cc7569996c9e0229bea7f378e3a824e"), kbsData.getCipherText());
|
||||
assertEquals(masterKey, kbsData.getMasterKey());
|
||||
|
||||
assertEquals("23a75cb1df1a87df45cc2ed167c2bdc85ab1220b847c88761b0005cac907fce5", kbsData.getMasterKey().deriveRegistrationLock());
|
||||
|
||||
String localPinHash = PinHashing.localPinHash(pin);
|
||||
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
|
||||
String localPinHash = PinHashUtil.localPinHash(pin);
|
||||
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.util.MessageTableUtils
|
||||
import org.thoughtcrime.securesms.util.MessageTableTestUtils
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.EditMessage
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -238,7 +238,7 @@ class EditMessageSyncProcessorTest {
|
||||
else -> cursor.getString(index)
|
||||
}
|
||||
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
|
||||
data = MessageTableUtils.typeColumnToString(cursor.getLong(index))
|
||||
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
|
||||
}
|
||||
|
||||
column to data
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.testing.InMemoryLogger
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.util.MessageTableUtils
|
||||
import org.thoughtcrime.securesms.util.MessageTableTestUtils
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceMetadata
|
||||
@@ -278,7 +278,7 @@ class MessageContentProcessorTestV2 {
|
||||
else -> cursor.getString(index)
|
||||
}
|
||||
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
|
||||
data = MessageTableUtils.typeColumnToString(cursor.getLong(index))
|
||||
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
|
||||
}
|
||||
|
||||
column to data
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.toProtoByteString
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.util.MessageTableTestUtils
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageContentProcessorV2__recipientStatusTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var processorV2: MessageContentProcessorV2
|
||||
private var envelopeTimestamp: Long = 0
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
processorV2 = MessageContentProcessorV2(harness.context)
|
||||
envelopeTimestamp = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
/**
|
||||
* Process sync group sent text transcript with partial send and then process second sync with recipient update
|
||||
* flag set to true with the rest of the send completed.
|
||||
*/
|
||||
@Test
|
||||
fun syncGroupSentTextMessageWithRecipientUpdateFollowup() {
|
||||
val (groupId, masterKey, groupRecipientId) = GroupTestingUtils.insertGroup(revision = 0, harness.self.asMember(), harness.others[0].asMember(), harness.others[1].asMember())
|
||||
val groupContextV2 = GroupContextV2.newBuilder().setRevision(0).setMasterKey(masterKey.serialize().toProtoByteString()).build()
|
||||
|
||||
val initialTextMessage = DataMessage.newBuilder().buildWith {
|
||||
body = MessageContentFuzzer.string()
|
||||
groupV2 = groupContextV2
|
||||
timestamp = envelopeTimestamp
|
||||
}
|
||||
|
||||
processorV2.process(
|
||||
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
|
||||
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0])),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
|
||||
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
|
||||
)
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(groupRecipientId)!!
|
||||
val firstSyncMessages = MessageTableTestUtils.getMessages(threadId)
|
||||
val firstMessageId = firstSyncMessages[0].id
|
||||
val firstReceiptInfo = SignalDatabase.groupReceipts.getGroupReceiptInfo(firstMessageId)
|
||||
|
||||
processorV2.process(
|
||||
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
|
||||
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0], harness.others[1]), recipientUpdate = true),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
|
||||
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
|
||||
)
|
||||
|
||||
val secondSyncMessages = MessageTableTestUtils.getMessages(threadId)
|
||||
val secondReceiptInfo = SignalDatabase.groupReceipts.getGroupReceiptInfo(firstMessageId)
|
||||
|
||||
firstSyncMessages.size assertIs 1
|
||||
firstSyncMessages[0].body assertIs initialTextMessage.body
|
||||
firstReceiptInfo.first { it.recipientId == harness.others[0] }.status assertIs GroupReceiptTable.STATUS_UNDELIVERED
|
||||
firstReceiptInfo.first { it.recipientId == harness.others[1] }.status assertIs GroupReceiptTable.STATUS_UNKNOWN
|
||||
|
||||
secondSyncMessages.size assertIs 1
|
||||
secondSyncMessages[0].body assertIs initialTextMessage.body
|
||||
secondReceiptInfo.first { it.recipientId == harness.others[0] }.status assertIs GroupReceiptTable.STATUS_UNDELIVERED
|
||||
secondReceiptInfo.first { it.recipientId == harness.others[1] }.status assertIs GroupReceiptTable.STATUS_UNDELIVERED
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class MessageContentProcessor__handleStoryMessageTest : MessageContentProcessorT
|
||||
decryptedGroupState
|
||||
)
|
||||
|
||||
val groupRecipient = Recipient.externalGroupExact(group)
|
||||
val groupRecipient = Recipient.externalGroupExact(group!!)
|
||||
val threadForGroup = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
|
||||
val insertResult = MmsHelper.insert(
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
|
||||
import org.signal.libsignal.protocol.state.IdentityKeyStore
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.PreKeyBundle
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
@@ -155,6 +156,11 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
override fun storeSignedPreKey(signedPreKeyId: Int, record: SignedPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsSignedPreKey(signedPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun removeSignedPreKey(signedPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun loadKyberPreKey(kyberPreKeyId: Int): KyberPreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun loadKyberPreKeys(): MutableList<KyberPreKeyRecord> = throw UnsupportedOperationException()
|
||||
override fun storeKyberPreKey(kyberPreKeyId: Int, record: KyberPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsKyberPreKey(kyberPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun markKyberPreKeyUsed(kyberPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
|
||||
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
|
||||
@@ -48,7 +48,7 @@ object FakeClientHelpers {
|
||||
val selfUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(myProfileKey)
|
||||
val themUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
|
||||
|
||||
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized)).targetUnidentifiedAccess
|
||||
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized, false), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized, false)).targetUnidentifiedAccess
|
||||
}
|
||||
|
||||
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Helper methods for creating groups for message processing tests et al.
|
||||
*/
|
||||
object GroupTestingUtils {
|
||||
fun member(serviceId: ServiceId, revision: Int = 0, role: Member.Role = Member.Role.ADMINISTRATOR): DecryptedMember {
|
||||
return DecryptedMember.newBuilder()
|
||||
.setUuid(serviceId.toByteString())
|
||||
.setJoinedAtRevision(revision)
|
||||
.setRole(role)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun insertGroup(revision: Int = 0, vararg members: DecryptedMember): TestGroupInfo {
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
val decryptedGroupState = DecryptedGroup.newBuilder()
|
||||
.addAllMembers(members.toList())
|
||||
.setRevision(revision)
|
||||
.setTitle(MessageContentFuzzer.string())
|
||||
.build()
|
||||
|
||||
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)!!
|
||||
val groupRecipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId)
|
||||
SignalDatabase.recipients.setProfileSharing(groupRecipientId, true)
|
||||
|
||||
return TestGroupInfo(groupId, groupMasterKey, groupRecipientId)
|
||||
}
|
||||
|
||||
fun RecipientId.asMember(): DecryptedMember {
|
||||
return Recipient.resolved(this).asMember()
|
||||
}
|
||||
|
||||
fun Recipient.asMember(): DecryptedMember {
|
||||
return member(serviceId = requireServiceId())
|
||||
}
|
||||
|
||||
data class TestGroupInfo(val groupId: GroupId.V2, val masterKey: GroupMasterKey, val recipientId: RecipientId)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.testing
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.thoughtcrime.securesms.database.model.toProtoByteString
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.thoughtcrime.securesms.messages.TestMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -12,6 +14,8 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Attach
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
@@ -41,13 +45,13 @@ object MessageContentFuzzer {
|
||||
/**
|
||||
* Create metadata to match an [Envelope].
|
||||
*/
|
||||
fun envelopeMetadata(source: RecipientId, destination: RecipientId): EnvelopeMetadata {
|
||||
fun envelopeMetadata(source: RecipientId, destination: RecipientId, groupId: GroupId.V2? = null): EnvelopeMetadata {
|
||||
return EnvelopeMetadata(
|
||||
sourceServiceId = Recipient.resolved(source).requireServiceId(),
|
||||
sourceE164 = null,
|
||||
sourceDeviceId = 1,
|
||||
sealedSender = true,
|
||||
groupId = null,
|
||||
groupId = groupId?.decodedId,
|
||||
destinationServiceId = Recipient.resolved(destination).requireServiceId()
|
||||
)
|
||||
}
|
||||
@@ -57,30 +61,60 @@ object MessageContentFuzzer {
|
||||
* - An expire timer value
|
||||
* - Bold style body ranges
|
||||
*/
|
||||
fun fuzzTextMessage(): Content {
|
||||
fun fuzzTextMessage(groupContextV2: GroupContextV2? = null): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().run {
|
||||
DataMessage.newBuilder().buildWith {
|
||||
body = string()
|
||||
if (random.nextBoolean()) {
|
||||
expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt())
|
||||
}
|
||||
if (random.nextBoolean()) {
|
||||
addBodyRanges(
|
||||
SignalServiceProtos.BodyRange.newBuilder().run {
|
||||
SignalServiceProtos.BodyRange.newBuilder().buildWith {
|
||||
start = 0
|
||||
length = 1
|
||||
style = SignalServiceProtos.BodyRange.Style.BOLD
|
||||
build()
|
||||
}
|
||||
)
|
||||
}
|
||||
build()
|
||||
if (groupContextV2 != null) {
|
||||
groupV2 = groupContextV2
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sync sent text message for the given [DataMessage].
|
||||
*/
|
||||
fun syncSentTextMessage(
|
||||
textMessage: DataMessage,
|
||||
deliveredTo: List<RecipientId>,
|
||||
recipientUpdate: Boolean = false
|
||||
): Content {
|
||||
return Content
|
||||
.newBuilder()
|
||||
.setSyncMessage(
|
||||
SyncMessage.newBuilder().buildWith {
|
||||
sent = SyncMessage.Sent.newBuilder().buildWith {
|
||||
timestamp = textMessage.timestamp
|
||||
message = textMessage
|
||||
isRecipientUpdate = recipientUpdate
|
||||
addAllUnidentifiedStatus(
|
||||
deliveredTo.map {
|
||||
SyncMessage.Sent.UnidentifiedDeliveryStatus.newBuilder().buildWith {
|
||||
destinationUuid = Recipient.resolved(it).requireServiceId().toString()
|
||||
unidentified = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random media message that may be:
|
||||
* - A text body
|
||||
@@ -91,7 +125,7 @@ object MessageContentFuzzer {
|
||||
fun fuzzMediaMessageWithBody(quoteAble: List<TestMessage> = emptyList()): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().run {
|
||||
DataMessage.newBuilder().buildWith {
|
||||
if (random.nextBoolean()) {
|
||||
body = string()
|
||||
}
|
||||
@@ -99,24 +133,22 @@ object MessageContentFuzzer {
|
||||
if (random.nextBoolean() && quoteAble.isNotEmpty()) {
|
||||
body = string()
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.newBuilder().run {
|
||||
quote = DataMessage.Quote.newBuilder().buildWith {
|
||||
id = quoted.envelope.timestamp
|
||||
authorUuid = quoted.metadata.sourceServiceId.toString()
|
||||
text = quoted.content.dataMessage.body
|
||||
addAllAttachments(quoted.content.dataMessage.attachmentsList)
|
||||
addAllBodyRanges(quoted.content.dataMessage.bodyRangesList)
|
||||
type = DataMessage.Quote.Type.NORMAL
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.newBuilder().run {
|
||||
quote = DataMessage.Quote.newBuilder().buildWith {
|
||||
id = random.nextLong(quoted.envelope.timestamp - 1000000, quoted.envelope.timestamp)
|
||||
authorUuid = quoted.metadata.sourceServiceId.toString()
|
||||
text = quoted.content.dataMessage.body
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +156,6 @@ object MessageContentFuzzer {
|
||||
val total = random.nextInt(1, 2)
|
||||
(0..total).forEach { _ -> addAttachments(attachmentPointer()) }
|
||||
}
|
||||
|
||||
build()
|
||||
}
|
||||
)
|
||||
.build()
|
||||
@@ -138,19 +168,16 @@ object MessageContentFuzzer {
|
||||
fun fuzzMediaMessageNoContent(previousMessages: List<TestMessage> = emptyList()): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().run {
|
||||
DataMessage.newBuilder().buildWith {
|
||||
if (random.nextFloat() < 0.25) {
|
||||
val reactTo = previousMessages.random(random)
|
||||
reaction = DataMessage.Reaction.newBuilder().run {
|
||||
reaction = DataMessage.Reaction.newBuilder().buildWith {
|
||||
emoji = emojis.random(random)
|
||||
remove = false
|
||||
targetAuthorUuid = reactTo.metadata.sourceServiceId.toString()
|
||||
targetSentTimestamp = reactTo.envelope.timestamp
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
build()
|
||||
}
|
||||
).build()
|
||||
}
|
||||
@@ -162,18 +189,16 @@ object MessageContentFuzzer {
|
||||
fun fuzzMediaMessageNoText(previousMessages: List<TestMessage> = emptyList()): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().run {
|
||||
DataMessage.newBuilder().buildWith {
|
||||
if (random.nextFloat() < 0.9) {
|
||||
sticker = DataMessage.Sticker.newBuilder().run {
|
||||
sticker = DataMessage.Sticker.newBuilder().buildWith {
|
||||
packId = byteString(length = 24)
|
||||
packKey = byteString(length = 128)
|
||||
stickerId = random.nextInt()
|
||||
data = attachmentPointer()
|
||||
emoji = emojis.random(random)
|
||||
build()
|
||||
}
|
||||
}
|
||||
build()
|
||||
}
|
||||
).build()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.protocol.util.Medium
|
||||
import org.signal.libsignal.svr2.PinHash
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -19,7 +20,6 @@ import org.thoughtcrime.securesms.pin.TokenData
|
||||
import org.thoughtcrime.securesms.test.BuildConfig
|
||||
import org.whispersystems.signalservice.api.KbsPinData
|
||||
import org.whispersystems.signalservice.api.KeyBackupService
|
||||
import org.whispersystems.signalservice.api.kbs.HashedPin
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
@@ -97,7 +97,7 @@ object MockProvider {
|
||||
|
||||
val session: KeyBackupService.RestoreSession = object : KeyBackupService.RestoreSession {
|
||||
override fun hashSalt(): ByteArray = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8")
|
||||
override fun restorePin(hashedPin: HashedPin?): KbsPinData = KbsPinData(MasterKey.createNew(SecureRandom()), null)
|
||||
override fun restorePin(hashedPin: PinHash?): KbsPinData = KbsPinData(MasterKey.createNew(SecureRandom()), null)
|
||||
}
|
||||
|
||||
val kbsService = ApplicationDependencies.getKeyBackupService(BuildConfig.KBS_ENCLAVE)
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
|
||||
/**
|
||||
* Helper methods for interacting with [MessageTable] in tests.
|
||||
*/
|
||||
object MessageTableTestUtils {
|
||||
|
||||
fun getMessages(threadId: Long): List<MessageRecord> {
|
||||
return MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId)).use {
|
||||
it.toList()
|
||||
}
|
||||
}
|
||||
|
||||
object MessageTableUtils {
|
||||
fun typeColumnToString(type: Long): String {
|
||||
return """
|
||||
isOutgoingMessageType:${MessageTypes.isOutgoingMessageType(type)}
|
||||
@@ -257,6 +257,307 @@
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltYellow"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_yellow"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_yellow" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_yellow" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltBubbles"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_bubbles"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_bubbles" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_bubbles" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltChat"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_chat"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_chat" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_chat" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltNews"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_news"
|
||||
android:label="@string/app_icon_label_news"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_news" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_news" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltNotes"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_notes"
|
||||
android:label="@string/app_icon_label_notes"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_notes" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_notes" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltColor"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_signal_color"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_color" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_color" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltDark"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_signal_dark"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_dark" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_dark" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltDarkVariant"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_signal_dark_variant"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_dark_variant" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_dark_variant" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltWhite"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_signal_white"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_white" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_white" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltWaves"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_waves"
|
||||
android:label="@string/app_icon_label_waves"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_waves" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_waves" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltWeather"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_weather"
|
||||
android:label="@string/app_icon_label_weather"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_weather" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_weather" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity android:name=".deeplinks.DeepLinkEntryActivity"
|
||||
android:exported="true"
|
||||
android:noHistory="true"
|
||||
@@ -298,6 +599,14 @@
|
||||
<data android:scheme="sgnl"
|
||||
android:host="signal.me" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="signal.link" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".conversation.v2.ConversationActivity"
|
||||
@@ -364,6 +673,11 @@
|
||||
android:windowSoftInputMode="stateAlwaysVisible"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".calls.links.details.CallLinkDetailsActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".calls.new.NewCallActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysVisible"
|
||||
@@ -628,11 +942,6 @@
|
||||
<activity android:name=".contacts.TurnOffContactJoinedNotificationsActivity"
|
||||
android:theme="@style/Theme.AppCompat.Dialog.Alert" />
|
||||
|
||||
<activity android:name=".messagerequests.MessageRequestMegaphoneActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".contactshare.ContactShareEditActivity"
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,7 @@ object AppCapabilities {
|
||||
@JvmStatic
|
||||
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
|
||||
return AccountAttributes.Capabilities(
|
||||
uuid = false,
|
||||
gv2 = true,
|
||||
storage = storageCapable,
|
||||
gv1Migration = true,
|
||||
senderKey = true,
|
||||
announcementGroup = true,
|
||||
changeNumber = true,
|
||||
|
||||
@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.gcm.FcmJobService;
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
@@ -143,8 +144,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
super.onCreate();
|
||||
|
||||
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("sqlcipher-init", () -> {
|
||||
AppStartup.getInstance().addBlocking("sqlcipher-init", () -> {
|
||||
SqlCipherLibraryLoader.load();
|
||||
SignalDatabase.init(this,
|
||||
DatabaseSecretProvider.getOrCreateDatabaseSecret(this),
|
||||
@@ -154,6 +154,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
})
|
||||
.addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("crash-handling", this::initializeCrashHandling)
|
||||
.addBlocking("rx-init", this::initializeRx)
|
||||
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
|
||||
@@ -211,6 +212,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
|
||||
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
|
||||
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -272,13 +274,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
|
||||
private void initializeSecurityProvider() {
|
||||
try {
|
||||
Class.forName("org.signal.aesgcmprovider.AesGcmCipher");
|
||||
} catch (ClassNotFoundException e) {
|
||||
Log.e(TAG, "Failed to find AesGcmCipher class");
|
||||
throw new ProviderInitializationException();
|
||||
}
|
||||
|
||||
int aesPosition = Security.insertProviderAt(new AesGcmProvider(), 1);
|
||||
Log.i(TAG, "Installed AesGcmProvider: " + aesPosition);
|
||||
|
||||
|
||||
@@ -108,12 +108,13 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onInviteToSignalClicked();
|
||||
void onActivatePaymentsClicked();
|
||||
void onSendPaymentClicked(@NonNull RecipientId recipientId);
|
||||
void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord);
|
||||
void onScheduledIndicatorClicked(@NonNull View view, @NonNull ConversationMessage conversationMessage);
|
||||
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||
boolean onUrlClicked(@NonNull String url);
|
||||
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
|
||||
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
|
||||
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
|
||||
void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord);
|
||||
void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.devicelist.Device;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
@@ -106,7 +107,9 @@ public class DeviceListFragment extends ListFragment
|
||||
if (data.isEmpty()) {
|
||||
empty.setVisibility(View.VISIBLE);
|
||||
TextSecurePreferences.setMultiDevice(getActivity(), false);
|
||||
SignalStore.misc().setHasLinkedDevices(false);
|
||||
} else {
|
||||
SignalStore.misc().setHasLinkedDevices(true);
|
||||
empty.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
@@ -26,7 +25,6 @@ import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.SplashScreenUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
@@ -82,6 +80,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
handleGroupLinkInIntent(getIntent());
|
||||
handleProxyInIntent(getIntent());
|
||||
handleSignalMeIntent(getIntent());
|
||||
handleCallLinkInIntent(getIntent());
|
||||
|
||||
CachedInflater.from(this).clear();
|
||||
|
||||
@@ -148,14 +147,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
|
||||
private void updateTabVisibility() {
|
||||
if (Stories.isFeatureEnabled() || FeatureFlags.callsTab()) {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
|
||||
} else {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorBackground));
|
||||
conversationListTabsViewModel.onChatsSelected();
|
||||
}
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
|
||||
}
|
||||
|
||||
public @NonNull MainNavigator getNavigator() {
|
||||
@@ -183,6 +176,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCallLinkInIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
public void onFirstRender() {
|
||||
onFirstRender = true;
|
||||
}
|
||||
|
||||
@@ -219,10 +219,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
Log.i(TAG, "onPause");
|
||||
super.onPause();
|
||||
|
||||
if (!isInPipMode() || isFinishing()) {
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
if (!viewModel.isCallStarting()) {
|
||||
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
|
||||
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
|
||||
@@ -369,6 +365,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
viewModel.setIsInPipMode(info.isInPictureInPictureMode());
|
||||
participantUpdateWindow.setEnabled(!info.isInPictureInPictureMode());
|
||||
callStateUpdatePopupWindow.setEnabled(!info.isInPictureInPictureMode());
|
||||
if (info.isInPictureInPictureMode()) {
|
||||
callScreen.maybeDismissAudioPicker();
|
||||
}
|
||||
viewModel.setIsLandscapeEnabled(info.isInPictureInPictureMode());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -832,7 +832,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@RequiresApi(31)
|
||||
@Override
|
||||
public void onAudioOutputChanged31(@NonNull int audioDeviceInfo) {
|
||||
public void onAudioOutputChanged31(@NonNull Integer audioDeviceInfo) {
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioDeviceInfo));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.audio
|
||||
|
||||
/**
|
||||
* A listener for when audio devices are added or removed, for example if a wired headset is plugged/unplugged or Bluetooth connected/disconnected.
|
||||
*/
|
||||
interface AudioDeviceUpdatedListener {
|
||||
fun onAudioDeviceUpdated()
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
|
||||
|
||||
internal const val TAG = "BluetoothVoiceNoteUtil"
|
||||
|
||||
sealed interface BluetoothVoiceNoteUtil {
|
||||
fun connectBluetoothScoConnection()
|
||||
fun disconnectBluetoothScoConnection()
|
||||
fun destroy()
|
||||
|
||||
companion object {
|
||||
fun create(context: Context, listener: () -> Unit, bluetoothPermissionDeniedHandler: () -> Unit): BluetoothVoiceNoteUtil {
|
||||
return if (Build.VERSION.SDK_INT >= 31) BluetoothVoiceNoteUtil31(listener) else BluetoothVoiceNoteUtilLegacy(context, listener, bluetoothPermissionDeniedHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
private class BluetoothVoiceNoteUtil31(val listener: () -> Unit) : BluetoothVoiceNoteUtil {
|
||||
override fun connectBluetoothScoConnection() {
|
||||
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
|
||||
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
|
||||
if (device != null) {
|
||||
val result: Boolean = audioManager.setCommunicationDevice(device)
|
||||
if (result) {
|
||||
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
|
||||
} else {
|
||||
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
|
||||
}
|
||||
listener()
|
||||
}
|
||||
|
||||
override fun disconnectBluetoothScoConnection() {
|
||||
Log.d(TAG, "Clearing call manager communication device.")
|
||||
ApplicationDependencies.getAndroidCallAudioManager().clearCommunicationDevice()
|
||||
}
|
||||
|
||||
override fun destroy() = Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulated logic for managing a Bluetooth connection withing the Fragment lifecycle for voice notes.
|
||||
*
|
||||
* @param context Context with reference to the main thread.
|
||||
* @param listener This will be executed on the main thread after the Bluetooth connection connects, or if it doesn't.
|
||||
* @param bluetoothPermissionDeniedHandler called when we detect the Bluetooth permission has been denied to our app.
|
||||
*/
|
||||
private class BluetoothVoiceNoteUtilLegacy(val context: Context, val listener: () -> Unit, val bluetoothPermissionDeniedHandler: () -> Unit) : BluetoothVoiceNoteUtil {
|
||||
private val commandAndControlThread: HandlerThread = SignalExecutors.getAndStartHandlerThread("voice-note-audio", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
|
||||
private val uiThreadHandler = Handler(context.mainLooper)
|
||||
private val audioHandler: SignalAudioHandler = SignalAudioHandler(commandAndControlThread.looper)
|
||||
private val deviceUpdatedListener: AudioDeviceUpdatedListener = object : AudioDeviceUpdatedListener {
|
||||
override fun onAudioDeviceUpdated() {
|
||||
if (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) {
|
||||
Log.d(TAG, "Bluetooth SCO connected. Starting voice note recording on UI thread.")
|
||||
uiThreadHandler.post { listener() }
|
||||
}
|
||||
}
|
||||
}
|
||||
private val signalBluetoothManager: SignalBluetoothManager = SignalBluetoothManager(context, deviceUpdatedListener, audioHandler)
|
||||
|
||||
private var hasWarnedAboutBluetooth = false
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT < 31) {
|
||||
audioHandler.post {
|
||||
signalBluetoothManager.start()
|
||||
Log.d(TAG, "Bluetooth manager started.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun connectBluetoothScoConnection() {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
|
||||
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
|
||||
if (device != null) {
|
||||
val result: Boolean = audioManager.setCommunicationDevice(device)
|
||||
if (result) {
|
||||
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
|
||||
} else {
|
||||
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
|
||||
}
|
||||
listener()
|
||||
} else {
|
||||
audioHandler.post {
|
||||
if (signalBluetoothManager.state.shouldUpdate()) {
|
||||
signalBluetoothManager.updateDevice()
|
||||
}
|
||||
val currentState = signalBluetoothManager.state
|
||||
if (currentState == SignalBluetoothManager.State.AVAILABLE) {
|
||||
signalBluetoothManager.startScoAudio()
|
||||
} else {
|
||||
Log.d(TAG, "Recording from phone mic because bluetooth state was " + currentState + ", not " + SignalBluetoothManager.State.AVAILABLE)
|
||||
uiThreadHandler.post {
|
||||
if (currentState == SignalBluetoothManager.State.PERMISSION_DENIED && !hasWarnedAboutBluetooth) {
|
||||
bluetoothPermissionDeniedHandler()
|
||||
hasWarnedAboutBluetooth = true
|
||||
}
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnectBluetoothScoConnection() {
|
||||
audioHandler.post {
|
||||
if (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) {
|
||||
signalBluetoothManager.stopScoAudio()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
audioHandler.post {
|
||||
signalBluetoothManager.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
package org.thoughtcrime.securesms.webrtc.audio
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
@@ -13,18 +18,19 @@ import android.media.AudioManager
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Manages the bluetooth lifecycle with a headset. This class doesn't make any
|
||||
* determination on if bluetooth should be used. It determines if a device is connected,
|
||||
* reports that to the [SignalAudioManager], and then handles connecting/disconnecting
|
||||
* to the device if requested by [SignalAudioManager].
|
||||
* reports that to the [org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager], and then handles connecting/disconnecting
|
||||
* to the device if requested by [org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager].
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // targetSdkVersion is still 30 (https://issuetracker.google.com/issues/201454155)
|
||||
class SignalBluetoothManager(
|
||||
private val context: Context,
|
||||
private val audioManager: FullSignalAudioManager,
|
||||
private val audioDeviceUpdatedListener: AudioDeviceUpdatedListener,
|
||||
private val handler: SignalAudioHandler
|
||||
) {
|
||||
|
||||
@@ -139,11 +145,6 @@ class SignalBluetoothManager(
|
||||
return false
|
||||
}
|
||||
|
||||
if (androidAudioManager.isBluetoothScoOn) {
|
||||
Log.i(TAG, "SCO connection already started")
|
||||
return true
|
||||
}
|
||||
|
||||
state = State.CONNECTING
|
||||
androidAudioManager.startBluetoothSco()
|
||||
androidAudioManager.isBluetoothScoOn = true
|
||||
@@ -202,10 +203,6 @@ class SignalBluetoothManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAudioDeviceState() {
|
||||
audioManager.updateAudioDeviceState()
|
||||
}
|
||||
|
||||
private fun startTimer() {
|
||||
handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT)
|
||||
}
|
||||
@@ -243,12 +240,12 @@ class SignalBluetoothManager(
|
||||
stopScoAudio()
|
||||
}
|
||||
|
||||
updateAudioDeviceState()
|
||||
audioDeviceUpdatedListener.onAudioDeviceUpdated()
|
||||
}
|
||||
|
||||
private fun onServiceConnected(proxy: BluetoothHeadset?) {
|
||||
bluetoothHeadset = proxy
|
||||
updateAudioDeviceState()
|
||||
audioDeviceUpdatedListener.onAudioDeviceUpdated()
|
||||
}
|
||||
|
||||
private fun onServiceDisconnected() {
|
||||
@@ -256,7 +253,7 @@ class SignalBluetoothManager(
|
||||
bluetoothHeadset = null
|
||||
bluetoothDevice = null
|
||||
state = State.UNAVAILABLE
|
||||
updateAudioDeviceState()
|
||||
audioDeviceUpdatedListener.onAudioDeviceUpdated()
|
||||
}
|
||||
|
||||
private fun onHeadsetConnectionStateChanged(connectionState: Int) {
|
||||
@@ -265,12 +262,12 @@ class SignalBluetoothManager(
|
||||
when (connectionState) {
|
||||
BluetoothHeadset.STATE_CONNECTED -> {
|
||||
scoConnectionAttempts = 0
|
||||
updateAudioDeviceState()
|
||||
audioDeviceUpdatedListener.onAudioDeviceUpdated()
|
||||
}
|
||||
|
||||
BluetoothHeadset.STATE_DISCONNECTED -> {
|
||||
stopScoAudio()
|
||||
updateAudioDeviceState()
|
||||
audioDeviceUpdatedListener.onAudioDeviceUpdated()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,7 +281,7 @@ class SignalBluetoothManager(
|
||||
Log.d(TAG, "Bluetooth audio SCO is now connected")
|
||||
state = State.CONNECTED
|
||||
scoConnectionAttempts = 0
|
||||
updateAudioDeviceState()
|
||||
audioDeviceUpdatedListener.onAudioDeviceUpdated()
|
||||
} else {
|
||||
Log.w(TAG, "Unexpected state ${audioState.toStateString()}")
|
||||
}
|
||||
@@ -296,7 +293,7 @@ class SignalBluetoothManager(
|
||||
Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.")
|
||||
return
|
||||
}
|
||||
updateAudioDeviceState()
|
||||
audioDeviceUpdatedListener.onAudioDeviceUpdated()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +344,9 @@ class SignalBluetoothManager(
|
||||
}
|
||||
} else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) {
|
||||
if (wasScoDisconnected(intent)) {
|
||||
handler.post(::updateAudioDeviceState)
|
||||
handler.post {
|
||||
audioDeviceUpdatedListener.onAudioDeviceUpdated()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Received broadcast of ${intent.action}")
|
||||
@@ -11,7 +11,6 @@ import android.util.Pair;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteConstraintException;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import java.net.URLDecoder
|
||||
|
||||
/**
|
||||
* Utility object for call links to try to keep some common logic in one place.
|
||||
*/
|
||||
object CallLinks {
|
||||
private const val ROOT_KEY = "key"
|
||||
|
||||
private val TAG = Log.tag(CallLinks::class.java)
|
||||
|
||||
fun url(linkKeyBytes: ByteArray) = "https://signal.link/call/#key=${Hex.dump(linkKeyBytes)}"
|
||||
|
||||
fun watchCallLink(roomId: CallLinkRoomId): Observable<CallLinkTable.CallLink> {
|
||||
return Observable.create { emitter ->
|
||||
|
||||
fun refresh() {
|
||||
val callLink = SignalDatabase.callLinks.getCallLinkByRoomId(roomId)
|
||||
if (callLink != null) {
|
||||
emitter.onNext(callLink)
|
||||
}
|
||||
}
|
||||
|
||||
val observer = DatabaseObserver.Observer {
|
||||
refresh()
|
||||
}
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerCallLinkObserver(roomId, observer)
|
||||
emitter.setCancellable {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun parseUrl(url: String): CallLinkRootKey? {
|
||||
val parts = url.split("#")
|
||||
if (parts.size != 2) {
|
||||
Log.w(TAG, "Invalid fragment delimiter count in url.")
|
||||
return null
|
||||
}
|
||||
|
||||
val fragment = parts[1]
|
||||
val fragmentParts = fragment.split("&")
|
||||
val fragmentQuery = fragmentParts.associate {
|
||||
val kv = it.split("=")
|
||||
if (kv.size != 2) {
|
||||
Log.w(TAG, "Invalid fragment keypair. Skipping.")
|
||||
}
|
||||
|
||||
val key = URLDecoder.decode(kv[0], "utf8")
|
||||
val value = URLDecoder.decode(kv[1], "utf8")
|
||||
|
||||
key to value
|
||||
}
|
||||
|
||||
val key = fragmentQuery[ROOT_KEY]
|
||||
if (key == null) {
|
||||
Log.w(TAG, "Root key not found in fragment query string.")
|
||||
return null
|
||||
}
|
||||
|
||||
// TODO Parse the key into a byte array
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import android.app.Dialog
|
||||
@@ -28,18 +33,21 @@ import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkViewModel
|
||||
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
|
||||
|
||||
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
|
||||
|
||||
private val viewModel: CreateCallLinkViewModel by viewModels(
|
||||
ownerProducer = { requireParentFragment() }
|
||||
)
|
||||
companion object {
|
||||
const val RESULT_KEY = "edit_call_link_name"
|
||||
}
|
||||
|
||||
private val args: EditCallLinkNameDialogFragmentArgs by navArgs()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -57,12 +65,11 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
|
||||
@Preview
|
||||
@Composable
|
||||
override fun DialogContent() {
|
||||
val viewModelCallName by viewModel.callName
|
||||
var callName by remember {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = viewModelCallName,
|
||||
selection = TextRange(viewModelCallName.length)
|
||||
text = args.name,
|
||||
selection = TextRange(args.name.length)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -97,7 +104,7 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Buttons.MediumTonal(
|
||||
onClick = {
|
||||
viewModel.setCallName(callName.text)
|
||||
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to callName.text))
|
||||
dismiss()
|
||||
},
|
||||
modifier = Modifier.align(End)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -15,12 +20,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
@@ -28,15 +35,39 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import java.time.Instant
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SignalCallRowPreview() {
|
||||
val avatarColor = remember { AvatarColor.random() }
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials.generate()
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(credentials.linkKeyBytes)),
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "Call Name",
|
||||
restrictions = org.signal.ringrtc.CallLinkState.Restrictions.NONE,
|
||||
expiration = Instant.MAX,
|
||||
revoked = false
|
||||
),
|
||||
avatarColor = avatarColor
|
||||
)
|
||||
}
|
||||
SignalTheme(false) {
|
||||
SignalCallRow(
|
||||
callName = "Call Name",
|
||||
callLink = "https://call.signal.org#blahblahblah",
|
||||
callLink = callLink,
|
||||
onJoinClicked = {}
|
||||
)
|
||||
}
|
||||
@@ -44,8 +75,7 @@ private fun SignalCallRowPreview() {
|
||||
|
||||
@Composable
|
||||
fun SignalCallRow(
|
||||
callName: String,
|
||||
callLink: String,
|
||||
callLink: CallLinkTable.CallLink,
|
||||
onJoinClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -60,15 +90,17 @@ fun SignalCallRow(
|
||||
)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val callColorPair = AvatarColorPair.create(LocalContext.current, callLink.avatarColor)
|
||||
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_display_bold_40),
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(Color(0xFF5151F6)),
|
||||
colorFilter = ColorFilter.tint(Color(callColorPair.foregroundColor)),
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(
|
||||
color = Color(0xFFE5E5FE),
|
||||
color = Color(callColorPair.backgroundColor),
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
@@ -81,10 +113,10 @@ fun SignalCallRow(
|
||||
.align(CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = callName.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
|
||||
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
|
||||
)
|
||||
Text(
|
||||
text = callLink,
|
||||
text = callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
|
||||
/**
|
||||
* Repository for performing update operations on call links:
|
||||
* <ul>
|
||||
* <li>Set name</li>
|
||||
* <li>Set restrictions</li>
|
||||
* <li>Revoke link</li>
|
||||
* </ul>
|
||||
*
|
||||
* All of these will delegate to the [SignalCallLinkManager] but will additionally update the database state.
|
||||
*/
|
||||
class UpdateCallLinkRepository(
|
||||
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
|
||||
) {
|
||||
fun setCallName(credentials: CallLinkCredentials, name: String): Single<UpdateCallLinkResult> {
|
||||
return callLinkManager
|
||||
.updateCallLinkName(
|
||||
credentials = credentials,
|
||||
name = name
|
||||
)
|
||||
.doOnSuccess(updateState(credentials))
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun setCallRestrictions(credentials: CallLinkCredentials, restrictions: CallLinkState.Restrictions): Single<UpdateCallLinkResult> {
|
||||
return callLinkManager
|
||||
.updateCallLinkRestrictions(
|
||||
credentials = credentials,
|
||||
restrictions = restrictions
|
||||
)
|
||||
.doOnSuccess(updateState(credentials))
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun revokeCallLink(credentials: CallLinkCredentials): Single<UpdateCallLinkResult> {
|
||||
return callLinkManager
|
||||
.updateCallLinkRevoked(credentials, true)
|
||||
.doOnSuccess(updateState(credentials))
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun updateState(credentials: CallLinkCredentials): (UpdateCallLinkResult) -> Unit {
|
||||
return { result ->
|
||||
if (result is UpdateCallLinkResult.Success) {
|
||||
SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.create
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,16 +32,26 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
import org.thoughtcrime.securesms.calls.links.SignalCallRow
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
||||
/**
|
||||
@@ -42,10 +59,24 @@ import org.thoughtcrime.securesms.util.Util
|
||||
*/
|
||||
class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CreateCallLinkBottomSheetDialogFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: CreateCallLinkViewModel by viewModels()
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
|
||||
if (bundle.containsKey(resultKey)) {
|
||||
setCallName(bundle.getString(resultKey)!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
Column(
|
||||
@@ -53,9 +84,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
.fillMaxWidth()
|
||||
.wrapContentSize(Alignment.Center)
|
||||
) {
|
||||
val callName: String by viewModel.callName
|
||||
val callLink: String by viewModel.callLink
|
||||
val approveAllMembers: Boolean by viewModel.approveAllMembers
|
||||
val callLink: CallLinkTable.CallLink by viewModel.callLink
|
||||
|
||||
Handle(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
|
||||
@@ -71,7 +100,6 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SignalCallRow(
|
||||
callName = callName,
|
||||
callLink = callLink,
|
||||
onJoinClicked = this@CreateCallLinkBottomSheetDialogFragment::onJoinClicked
|
||||
)
|
||||
@@ -84,10 +112,10 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = approveAllMembers,
|
||||
checked = callLink.state.restrictions == CallLinkState.Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__approve_all_members),
|
||||
onCheckChanged = viewModel::setApproveAllMembers,
|
||||
modifier = Modifier.clickable(onClick = viewModel::toggleApproveAllMembers)
|
||||
onCheckChanged = this@CreateCallLinkBottomSheetDialogFragment::setApproveAllMembers,
|
||||
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::toggleApproveAllMembers)
|
||||
)
|
||||
|
||||
Dividers.Default()
|
||||
@@ -123,51 +151,133 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCallName(callName: String) {
|
||||
lifecycleDisposable += viewModel.setCallName(callName).subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to update call link name")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun setApproveAllMembers(approveAllMembers: Boolean) {
|
||||
lifecycleDisposable += viewModel.setApproveAllMembers(approveAllMembers).subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to update call link restrictions")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun toggleApproveAllMembers() {
|
||||
lifecycleDisposable += viewModel.toggleApproveAllMembers().subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to update call link restrictions")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onAddACallNameClicked() {
|
||||
EditCallLinkNameDialogFragment().show(childFragmentManager, null)
|
||||
}
|
||||
|
||||
private fun onJoinClicked() {
|
||||
}
|
||||
|
||||
private fun onDoneClicked() {
|
||||
}
|
||||
|
||||
private fun onShareViaSignalClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
|
||||
MultiselectForwardFragment.showFullScreen(
|
||||
childFragmentManager,
|
||||
MultiselectForwardFragmentArgs(
|
||||
canSendToNonPush = false,
|
||||
multiShareArgs = listOf(
|
||||
MultiShareArgs.Builder()
|
||||
.withDraftText(snapshot)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
findNavController().navigate(
|
||||
CreateCallLinkBottomSheetDialogFragmentDirections.actionCreateCallLinkBottomSheetToEditCallLinkNameDialogFragment(snapshot.state.name)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onJoinClicked() {
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
CommunicationActions.startVideoCall(requireActivity(), it.recipient)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onDoneClicked() {
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> dismissAllowingStateLoss()
|
||||
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onShareViaSignalClicked() {
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
MultiselectForwardFragment.showFullScreen(
|
||||
childFragmentManager,
|
||||
MultiselectForwardFragmentArgs(
|
||||
canSendToNonPush = false,
|
||||
multiShareArgs = listOf(
|
||||
MultiShareArgs.Builder()
|
||||
.withDraftText(CallLinks.url(viewModel.linkKeyBytes))
|
||||
.build()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onCopyLinkClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
Util.copyToClipboard(requireContext(), snapshot)
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onShareLinkClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(snapshot)
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(CallLinks.url(viewModel.linkKeyBytes))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
is EnsureCallLinkCreatedResult.Failure -> {
|
||||
Log.w(TAG, "Failed to create link: $it")
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCreateCallLinkFailure(failure: CreateCallLinkResult.Failure) {
|
||||
Log.w(TAG, "Failed to create call link: $failure")
|
||||
toastFailure()
|
||||
}
|
||||
|
||||
private fun handleError(throwable: Throwable) {
|
||||
Log.w(TAG, "Failed to create call link.", throwable)
|
||||
toastFailure()
|
||||
}
|
||||
|
||||
private fun toastFailure() {
|
||||
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.create
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
|
||||
|
||||
/**
|
||||
* Repository for creating new call links. This will delegate to the [SignalCallLinkManager]
|
||||
* but will also ensure the database is updated.
|
||||
*/
|
||||
class CreateCallLinkRepository(
|
||||
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
|
||||
) {
|
||||
fun ensureCallLinkCreated(credentials: CallLinkCredentials, avatarColor: AvatarColor): Single<EnsureCallLinkCreatedResult> {
|
||||
val callLinkRecipientId = Single.fromCallable {
|
||||
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId)
|
||||
}
|
||||
|
||||
return callLinkRecipientId.flatMap { recipientId ->
|
||||
if (recipientId.isPresent) {
|
||||
Single.just(EnsureCallLinkCreatedResult.Success(Recipient.resolved(recipientId.get())))
|
||||
} else {
|
||||
callLinkManager.createCallLink(credentials).map {
|
||||
when (it) {
|
||||
is CreateCallLinkResult.Success -> {
|
||||
SignalDatabase.callLinks.insertCallLink(
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = credentials.roomId,
|
||||
credentials = credentials,
|
||||
state = it.state,
|
||||
avatarColor = avatarColor
|
||||
)
|
||||
)
|
||||
|
||||
EnsureCallLinkCreatedResult.Success(
|
||||
Recipient.resolved(
|
||||
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId).get()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is CreateCallLinkResult.Failure -> EnsureCallLinkCreatedResult.Failure(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,98 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.create
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import java.time.Instant
|
||||
|
||||
class CreateCallLinkViewModel : ViewModel() {
|
||||
private val _callName: MutableState<String> = mutableStateOf("")
|
||||
private val _callLink: MutableState<String> = mutableStateOf("")
|
||||
private val _approveAllMembers: MutableState<Boolean> = mutableStateOf(false)
|
||||
class CreateCallLinkViewModel(
|
||||
private val repository: CreateCallLinkRepository = CreateCallLinkRepository(),
|
||||
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
|
||||
) : ViewModel() {
|
||||
private val credentials = CallLinkCredentials.generate()
|
||||
private val avatarColor = AvatarColor.random()
|
||||
private val _callLink: MutableState<CallLinkTable.CallLink> = mutableStateOf(
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = credentials.roomId,
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "",
|
||||
restrictions = Restrictions.NONE,
|
||||
revoked = false,
|
||||
expiration = Instant.MAX
|
||||
),
|
||||
avatarColor = avatarColor
|
||||
)
|
||||
)
|
||||
|
||||
val callName: State<String> = _callName
|
||||
val callLink: State<String> = _callLink
|
||||
val approveAllMembers: State<Boolean> = _approveAllMembers
|
||||
val callLink: State<CallLinkTable.CallLink> = _callLink
|
||||
val linkKeyBytes: ByteArray = credentials.linkKeyBytes
|
||||
|
||||
fun setApproveAllMembers(approveAllMembers: Boolean) {
|
||||
_approveAllMembers.value = approveAllMembers
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
disposables += CallLinks.watchCallLink(credentials.roomId)
|
||||
.subscribeBy {
|
||||
_callLink.value = it
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleApproveAllMembers() {
|
||||
_approveAllMembers.value = !_approveAllMembers.value
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
fun setCallName(callName: String) {
|
||||
_callName.value = callName
|
||||
fun commitCallLink(): Single<EnsureCallLinkCreatedResult> {
|
||||
return repository.ensureCallLinkCreated(credentials, avatarColor)
|
||||
}
|
||||
|
||||
fun setCallLink(callLink: String) {
|
||||
_callLink.value = callLink
|
||||
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
|
||||
return commitCallLink()
|
||||
.flatMap {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallRestrictions(
|
||||
credentials,
|
||||
if (approveAllMembers) Restrictions.ADMIN_APPROVAL else Restrictions.NONE
|
||||
)
|
||||
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleApproveAllMembers(): Single<UpdateCallLinkResult> {
|
||||
return setApproveAllMembers(_callLink.value.state.restrictions != Restrictions.ADMIN_APPROVAL)
|
||||
}
|
||||
|
||||
fun setCallName(callName: String): Single<UpdateCallLinkResult> {
|
||||
return commitCallLink()
|
||||
.flatMap {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallName(
|
||||
credentials,
|
||||
callName
|
||||
)
|
||||
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.create
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
|
||||
|
||||
sealed interface EnsureCallLinkCreatedResult {
|
||||
data class Success(val recipient: Recipient) : EnsureCallLinkCreatedResult
|
||||
data class Failure(val failure: CreateCallLinkResult.Failure) : EnsureCallLinkCreatedResult
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
|
||||
class CallLinkDetailsActivity : FragmentWrapperActivity() {
|
||||
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details, intent.extras!!.getBundle(BUNDLE))
|
||||
|
||||
companion object {
|
||||
|
||||
private const val BUNDLE = "bundle"
|
||||
|
||||
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
|
||||
return Intent(context, CallLinkDetailsActivity::class.java)
|
||||
.putExtra(BUNDLE, CallLinkDetailsFragmentArgs.Builder(callLinkRoomId).build().toBundle())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
import org.thoughtcrime.securesms.calls.links.SignalCallRow
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Provides detailed info about a call link and allows the owner of that link
|
||||
* to modify call properties.
|
||||
*/
|
||||
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CallLinkDetailsFragment::class.java)
|
||||
}
|
||||
|
||||
private val args: CallLinkDetailsFragmentArgs by navArgs()
|
||||
private val viewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
|
||||
CallLinkDetailsViewModel.Factory(args.roomId)
|
||||
})
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
|
||||
if (bundle.containsKey(resultKey)) {
|
||||
setName(bundle.getString(resultKey)!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
|
||||
CallLinkDetails(
|
||||
state,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNavigationClicked() {
|
||||
ActivityCompat.finishAfterTransition(requireActivity())
|
||||
}
|
||||
|
||||
override fun onJoinClicked() {
|
||||
val recipientSnapshot = viewModel.recipientSnapshot
|
||||
if (recipientSnapshot != null) {
|
||||
CommunicationActions.startVideoCall(this, recipientSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEditNameClicked() {
|
||||
val name = viewModel.nameSnapshot
|
||||
findNavController().navigate(
|
||||
CallLinkDetailsFragmentDirections.actionCallLinkDetailsFragmentToEditCallLinkNameDialogFragment(name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onShareClicked() {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleteClicked() {
|
||||
viewModel.setDisplayRevocationDialog(true)
|
||||
}
|
||||
|
||||
override fun onDeleteConfirmed() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
lifecycleDisposable += viewModel.revoke().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is UpdateCallLinkResult.Success -> ActivityCompat.finishAfterTransition(requireActivity())
|
||||
else -> {
|
||||
Log.w(TAG, "Failed to revoke. $it")
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
}, onError = handleError("onDeleteClicked"))
|
||||
}
|
||||
|
||||
override fun onDeleteCanceled() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
}
|
||||
|
||||
override fun onApproveAllMembersChanged(checked: Boolean) {
|
||||
lifecycleDisposable += viewModel.setApproveAllMembers(checked).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to change restrictions. $it")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = handleError("onApproveAllMembersChanged"))
|
||||
}
|
||||
|
||||
private fun setName(name: String) {
|
||||
lifecycleDisposable += viewModel.setName(name).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to set name. $it")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = handleError("setName"))
|
||||
}
|
||||
|
||||
private fun handleError(method: String): (throwable: Throwable) -> Unit {
|
||||
return {
|
||||
Log.w(TAG, "Failure during $method", it)
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toastFailure() {
|
||||
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private interface CallLinkDetailsCallback {
|
||||
fun onNavigationClicked()
|
||||
fun onJoinClicked()
|
||||
fun onEditNameClicked()
|
||||
fun onShareClicked()
|
||||
fun onDeleteClicked()
|
||||
fun onDeleteConfirmed()
|
||||
fun onDeleteCanceled()
|
||||
fun onApproveAllMembersChanged(checked: Boolean)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CallLinkDetailsPreview() {
|
||||
val avatarColor = remember {
|
||||
AvatarColor.random()
|
||||
}
|
||||
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials.generate()
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = credentials.roomId,
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "Call Name",
|
||||
revoked = false,
|
||||
restrictions = Restrictions.NONE,
|
||||
expiration = Instant.MAX
|
||||
),
|
||||
avatarColor = avatarColor
|
||||
)
|
||||
}
|
||||
|
||||
SignalTheme(false) {
|
||||
CallLinkDetails(
|
||||
CallLinkDetailsState(
|
||||
false,
|
||||
callLink
|
||||
),
|
||||
object : CallLinkDetailsCallback {
|
||||
override fun onDeleteConfirmed() = Unit
|
||||
override fun onDeleteCanceled() = Unit
|
||||
override fun onNavigationClicked() = Unit
|
||||
override fun onJoinClicked() = Unit
|
||||
override fun onEditNameClicked() = Unit
|
||||
override fun onShareClicked() = Unit
|
||||
override fun onDeleteClicked() = Unit
|
||||
override fun onApproveAllMembersChanged(checked: Boolean) = Unit
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallLinkDetails(
|
||||
state: CallLinkDetailsState,
|
||||
callback: CallLinkDetailsCallback
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
|
||||
onNavigationClick = callback::onNavigationClicked,
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24)
|
||||
) { paddingValues ->
|
||||
if (state.callLink == null) {
|
||||
return@Settings
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(paddingValues)) {
|
||||
SignalCallRow(
|
||||
callLink = state.callLink,
|
||||
onJoinClicked = callback::onJoinClicked,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
|
||||
modifier = Modifier.clickable(onClick = callback::onEditNameClicked)
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
|
||||
onCheckChanged = callback::onApproveAllMembersChanged
|
||||
)
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
|
||||
modifier = Modifier.clickable(onClick = callback::onShareClicked)
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
|
||||
foregroundTint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.clickable(onClick = callback::onDeleteClicked)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.displayRevocationDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
|
||||
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
|
||||
confirm = stringResource(id = R.string.delete),
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onConfirm = callback::onDeleteConfirmed,
|
||||
onDismiss = callback::onDeleteCanceled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.ReadCallLinkResult
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
|
||||
|
||||
class CallLinkDetailsRepository(
|
||||
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
|
||||
) {
|
||||
fun refreshCallLinkState(callLinkRoomId: CallLinkRoomId): Disposable {
|
||||
return Maybe.fromCallable<CallLinkTable.CallLink> { SignalDatabase.callLinks.getCallLinkByRoomId(callLinkRoomId) }
|
||||
.flatMapSingle { callLinkManager.readCallLink(it.credentials!!) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is ReadCallLinkResult.Success -> SignalDatabase.callLinks.updateCallLinkState(callLinkRoomId, result.callLinkState)
|
||||
is ReadCallLinkResult.Failure -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun watchCallLinkRecipient(callLinkRoomId: CallLinkRoomId): Observable<Recipient> {
|
||||
return Maybe.fromCallable<RecipientId> { SignalDatabase.recipients.getByCallLinkRoomId(callLinkRoomId).orNull() }
|
||||
.flatMapObservable { Recipient.observable(it) }
|
||||
.distinctUntilChanged { a, b -> a.hasSameContent(b) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
|
||||
@Immutable
|
||||
data class CallLinkDetailsState(
|
||||
val displayRevocationDialog: Boolean = false,
|
||||
val callLink: CallLinkTable.CallLink? = null
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
|
||||
class CallLinkDetailsViewModel(
|
||||
callLinkRoomId: CallLinkRoomId,
|
||||
repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
|
||||
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
|
||||
) : ViewModel() {
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val _state: MutableState<CallLinkDetailsState> = mutableStateOf(CallLinkDetailsState())
|
||||
val state: State<CallLinkDetailsState> = _state
|
||||
val nameSnapshot: String
|
||||
get() = state.value.callLink?.state?.name ?: error("Call link not loaded yet.")
|
||||
|
||||
val rootKeySnapshot: ByteArray
|
||||
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
|
||||
|
||||
private val recipientSubject = BehaviorSubject.create<Recipient>()
|
||||
val recipientSnapshot: Recipient?
|
||||
get() = recipientSubject.value
|
||||
|
||||
init {
|
||||
disposables += repository.refreshCallLinkState(callLinkRoomId)
|
||||
disposables += CallLinks.watchCallLink(callLinkRoomId).subscribeBy {
|
||||
_state.value = _state.value.copy(callLink = it)
|
||||
}
|
||||
|
||||
disposables += repository
|
||||
.watchCallLinkRecipient(callLinkRoomId)
|
||||
.subscribeBy(onNext = recipientSubject::onNext)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
fun setDisplayRevocationDialog(displayRevocationDialog: Boolean) {
|
||||
_state.value = _state.value.copy(displayRevocationDialog = displayRevocationDialog)
|
||||
}
|
||||
|
||||
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
return mutationRepository.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
|
||||
}
|
||||
|
||||
fun setName(name: String): Single<UpdateCallLinkResult> {
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
return mutationRepository.setCallName(credentials, name)
|
||||
}
|
||||
|
||||
fun revoke(): Single<UpdateCallLinkResult> {
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
return mutationRepository.revokeCallLink(credentials)
|
||||
}
|
||||
|
||||
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.text.style.TextAppearanceSpan
|
||||
import android.view.View
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
@@ -15,6 +16,7 @@ import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBin
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.SearchUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
@@ -60,6 +62,14 @@ class CallLogAdapter(
|
||||
inflater = CallLogCreateCallLinkItemBinding::inflate
|
||||
)
|
||||
)
|
||||
|
||||
registerFactory(
|
||||
CallLinkModel::class.java,
|
||||
BindingFactory(
|
||||
creator = { CallLinkModelViewHolder(it, callbacks::onCallLinkClicked, callbacks::onCallLinkLongClicked, callbacks::onStartVideoCallClicked) },
|
||||
inflater = CallLogAdapterItemBinding::inflate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun submitCallRows(
|
||||
@@ -74,6 +84,7 @@ class CallLogAdapter(
|
||||
.map {
|
||||
when (it) {
|
||||
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
|
||||
is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount)
|
||||
is CallLogRow.ClearFilter -> ClearFilterModel()
|
||||
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
|
||||
}
|
||||
@@ -118,6 +129,44 @@ class CallLogAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private class CallLinkModel(
|
||||
val callLink: CallLogRow.CallLink,
|
||||
val selectionState: CallLogSelectionState,
|
||||
val itemCount: Int
|
||||
) : MappingModel<CallLinkModel> {
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: CallLinkModel): Boolean {
|
||||
return callLink.record.roomId == newItem.callLink.record.roomId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: CallLinkModel): Boolean {
|
||||
return callLink == newItem.callLink &&
|
||||
isSelectionStateTheSame(newItem) &&
|
||||
isItemCountTheSame(newItem)
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: CallLinkModel): Any? {
|
||||
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
|
||||
CallModel.PAYLOAD_SELECTION_STATE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSelectionStateTheSame(newItem: CallLinkModel): Boolean {
|
||||
return selectionState.contains(callLink.id) == newItem.selectionState.contains(newItem.callLink.id) &&
|
||||
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
|
||||
}
|
||||
|
||||
private fun isItemCountTheSame(newItem: CallLinkModel): Boolean {
|
||||
return itemCount == newItem.itemCount
|
||||
}
|
||||
}
|
||||
|
||||
private class ClearFilterModel : MappingModel<ClearFilterModel> {
|
||||
override fun areItemsTheSame(newItem: ClearFilterModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
|
||||
@@ -129,6 +178,54 @@ class CallLogAdapter(
|
||||
override fun areContentsTheSame(newItem: CreateCallLinkModel): Boolean = true
|
||||
}
|
||||
|
||||
private class CallLinkModelViewHolder(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
|
||||
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
|
||||
private val onStartVideoCallClicked: (Recipient) -> Unit
|
||||
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallLinkModel) {
|
||||
itemView.setOnClickListener {
|
||||
onCallLinkClicked(model.callLink)
|
||||
}
|
||||
|
||||
itemView.setOnLongClickListener {
|
||||
onCallLinkLongClicked(itemView, model.callLink)
|
||||
}
|
||||
|
||||
itemView.isSelected = model.selectionState.contains(model.callLink.id)
|
||||
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
|
||||
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
|
||||
|
||||
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
|
||||
return
|
||||
}
|
||||
|
||||
binding.callRecipientAvatar.setAvatar(model.callLink.recipient)
|
||||
|
||||
val callLinkName = model.callLink.record.state.name.takeIf { it.isNotEmpty() }
|
||||
?: context.getString(R.string.WebRtcCallView__signal_call)
|
||||
|
||||
binding.callRecipientName.text = SearchUtil.getHighlightedSpan(
|
||||
Locale.getDefault(),
|
||||
{ arrayOf(TextAppearanceSpan(context, R.style.Signal_Text_TitleSmall)) },
|
||||
callLinkName,
|
||||
model.callLink.searchQuery,
|
||||
SearchUtil.MATCH_ALL
|
||||
)
|
||||
|
||||
binding.callInfo.setRelativeDrawables(start = R.drawable.symbol_link_compact_16)
|
||||
binding.callInfo.setText(R.string.CallLogAdapter__call_link)
|
||||
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.setOnClickListener {
|
||||
onStartVideoCallClicked(model.callLink.recipient)
|
||||
}
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
private class CallModelViewHolder(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallClicked: (CallLogRow.Call) -> Unit,
|
||||
@@ -153,13 +250,27 @@ class CallLogAdapter(
|
||||
return
|
||||
}
|
||||
|
||||
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true)
|
||||
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
|
||||
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
|
||||
presentRecipientDetails(model.call.peer, model.call.searchQuery)
|
||||
presentCallInfo(model.call, model.call.date)
|
||||
presentCallType(model)
|
||||
}
|
||||
|
||||
private fun presentRecipientDetails(recipient: Recipient, searchQuery: String?) {
|
||||
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), recipient, true)
|
||||
binding.callRecipientBadge.setBadgeFromRecipient(recipient)
|
||||
binding.callRecipientName.text = if (searchQuery != null) {
|
||||
SearchUtil.getHighlightedSpan(
|
||||
Locale.getDefault(),
|
||||
{ arrayOf(TextAppearanceSpan(context, R.style.Signal_Text_TitleSmall)) },
|
||||
recipient.getDisplayName(context),
|
||||
searchQuery,
|
||||
SearchUtil.MATCH_ALL
|
||||
)
|
||||
} else {
|
||||
recipient.getDisplayName(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentCallInfo(call: CallLogRow.Call, date: Long) {
|
||||
val callState = context.getString(getCallStateStringRes(call.record))
|
||||
binding.callInfo.text = context.getString(
|
||||
@@ -181,7 +292,7 @@ class CallLogAdapter(
|
||||
if (call.record.event == CallTable.Event.MISSED) {
|
||||
R.color.signal_colorError
|
||||
} else {
|
||||
R.color.signal_colorOnSurface
|
||||
R.color.signal_colorOnSurfaceVariant
|
||||
}
|
||||
)
|
||||
|
||||
@@ -219,6 +330,7 @@ class CallLogAdapter(
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
|
||||
CallLogRow.GroupCallState.ACTIVE, CallLogRow.GroupCallState.LOCAL_USER_JOINED -> {
|
||||
binding.callType.visible = false
|
||||
binding.groupCallButton.visible = true
|
||||
@@ -249,6 +361,7 @@ class CallLogAdapter(
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
else -> error("Unexpected type ${call.type}")
|
||||
}
|
||||
}
|
||||
@@ -269,6 +382,7 @@ class CallLogAdapter(
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
else -> error("Unexpected type ${call.messageType}")
|
||||
}
|
||||
}
|
||||
@@ -308,11 +422,21 @@ class CallLogAdapter(
|
||||
*/
|
||||
fun onCallClicked(callLogRow: CallLogRow.Call)
|
||||
|
||||
/**
|
||||
* Invoked when a call link row is clicked
|
||||
*/
|
||||
fun onCallLinkClicked(callLogRow: CallLogRow.CallLink)
|
||||
|
||||
/**
|
||||
* Invoked when a call row is long-clicked
|
||||
*/
|
||||
fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean
|
||||
|
||||
/**
|
||||
* Invoked when a call link row is long-clicked
|
||||
*/
|
||||
fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean
|
||||
|
||||
/**
|
||||
* Invoked when the clear filter button is pressed
|
||||
*/
|
||||
@@ -321,11 +445,11 @@ class CallLogAdapter(
|
||||
/**
|
||||
* Invoked when user presses the audio icon
|
||||
*/
|
||||
fun onStartAudioCallClicked(peer: Recipient)
|
||||
fun onStartAudioCallClicked(recipient: Recipient)
|
||||
|
||||
/**
|
||||
* Invoked when user presses the video icon
|
||||
*/
|
||||
fun onStartVideoCallClicked(peer: Recipient)
|
||||
fun onStartVideoCallClicked(recipient: Recipient)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
@@ -30,28 +32,47 @@ class CallLogContextMenu(
|
||||
}
|
||||
.show(
|
||||
listOfNotNull(
|
||||
getVideoCallActionItem(call),
|
||||
getVideoCallActionItem(call.peer),
|
||||
getAudioCallActionItem(call),
|
||||
getGoToChatActionItem(call),
|
||||
getInfoActionItem(call),
|
||||
getInfoActionItem(call.peer, (call.id as CallLogRow.Id.Call).children.toLongArray()),
|
||||
getSelectActionItem(call),
|
||||
getDeleteActionItem(call)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVideoCallActionItem(call: CallLogRow.Call): ActionItem {
|
||||
fun show(recyclerView: RecyclerView, anchor: View, callLink: CallLogRow.CallLink) {
|
||||
recyclerView.suppressLayout(true)
|
||||
anchor.isSelected = true
|
||||
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
|
||||
.onDismiss {
|
||||
anchor.isSelected = false
|
||||
recyclerView.suppressLayout(false)
|
||||
}
|
||||
.show(
|
||||
listOfNotNull(
|
||||
getVideoCallActionItem(callLink.recipient),
|
||||
getInfoActionItem(callLink.recipient, longArrayOf()),
|
||||
getSelectActionItem(callLink),
|
||||
getDeleteActionItem(callLink)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVideoCallActionItem(peer: Recipient): ActionItem {
|
||||
// TODO [alex] -- Need group calling disposition to make this correct
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_video_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__video_call)
|
||||
) {
|
||||
CommunicationActions.startVideoCall(fragment, call.peer)
|
||||
CommunicationActions.startVideoCall(fragment, peer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAudioCallActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.peer.isGroup) {
|
||||
if (call.peer.isCallLink || call.peer.isGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -63,26 +84,32 @@ class CallLogContextMenu(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_open_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
|
||||
) {
|
||||
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
|
||||
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
return when {
|
||||
call.peer.isCallLink -> null
|
||||
else -> ActionItem(
|
||||
iconRes = R.drawable.symbol_open_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
|
||||
) {
|
||||
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInfoActionItem(call: CallLogRow.Call): ActionItem {
|
||||
private fun getInfoActionItem(peer: Recipient, messageIds: LongArray): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_info_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__info)
|
||||
) {
|
||||
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.record.messageId!!))
|
||||
val intent = when {
|
||||
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
|
||||
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
|
||||
}
|
||||
fragment.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectActionItem(call: CallLogRow.Call): ActionItem {
|
||||
private fun getSelectActionItem(call: CallLogRow): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_check_circle_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__select)
|
||||
@@ -91,8 +118,8 @@ class CallLogContextMenu(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.record.event == CallTable.Event.ONGOING) {
|
||||
private fun getDeleteActionItem(call: CallLogRow): ActionItem? {
|
||||
if (call is CallLogRow.Call && call.record.event == CallTable.Event.ONGOING) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -105,7 +132,7 @@ class CallLogContextMenu(
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun startSelection(call: CallLogRow.Call)
|
||||
fun deleteCall(call: CallLogRow.Call)
|
||||
fun startSelection(call: CallLogRow)
|
||||
fun deleteCall(call: CallLogRow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
|
||||
@@ -311,9 +312,23 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
override fun onCallClicked(callLogRow: CallLogRow.Call) {
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
} else {
|
||||
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, (callLogRow.id as CallLogRow.Id.Call).children.toLongArray())
|
||||
} else if (!callLogRow.peer.isCallLink) {
|
||||
val intent = ConversationSettingsActivity.forCall(
|
||||
requireContext(),
|
||||
callLogRow.peer,
|
||||
(callLogRow.id as CallLogRow.Id.Call).children.toLongArray()
|
||||
)
|
||||
startActivity(intent)
|
||||
} else {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCallLinkClicked(callLogRow: CallLogRow.CallLink) {
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
} else {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.record.roomId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,25 +337,30 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean {
|
||||
callLogContextMenu.show(binding.recycler, itemView, callLinkLogRow)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onClearFilterClicked() {
|
||||
binding.pullView.toggle()
|
||||
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
|
||||
}
|
||||
|
||||
override fun onStartAudioCallClicked(peer: Recipient) {
|
||||
CommunicationActions.startVoiceCall(this, peer)
|
||||
override fun onStartAudioCallClicked(recipient: Recipient) {
|
||||
CommunicationActions.startVoiceCall(this, recipient)
|
||||
}
|
||||
|
||||
override fun onStartVideoCallClicked(peer: Recipient) {
|
||||
CommunicationActions.startVideoCall(this, peer)
|
||||
override fun onStartVideoCallClicked(recipient: Recipient) {
|
||||
CommunicationActions.startVideoCall(this, recipient)
|
||||
}
|
||||
|
||||
override fun startSelection(call: CallLogRow.Call) {
|
||||
override fun startSelection(call: CallLogRow) {
|
||||
callLogActionMode.start()
|
||||
viewModel.toggleSelected(call.id)
|
||||
}
|
||||
|
||||
override fun deleteCall(call: CallLogRow.Call) {
|
||||
override fun deleteCall(call: CallLogRow) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
|
||||
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
|
||||
|
||||
@@ -12,28 +12,62 @@ class CallLogPagedDataSource(
|
||||
private val hasFilter = filter == CallLogFilter.MISSED
|
||||
private val hasCallLinkRow = FeatureFlags.adHocCalling() && filter == CallLogFilter.ALL && query.isNullOrEmpty()
|
||||
|
||||
private var callsCount = 0
|
||||
private var callEventsCount = 0
|
||||
private var callLinksCount = 0
|
||||
|
||||
override fun size(): Int {
|
||||
callsCount = repository.getCallsCount(query, filter)
|
||||
return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt()
|
||||
callEventsCount = repository.getCallsCount(query, filter)
|
||||
callLinksCount = repository.getCallLinksCount(query, filter)
|
||||
return callEventsCount + callLinksCount + hasFilter.toInt() + hasCallLinkRow.toInt()
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
|
||||
val calls = mutableListOf<CallLogRow>()
|
||||
val callLimit = length - hasCallLinkRow.toInt()
|
||||
|
||||
if (start == 0 && length >= 1 && hasCallLinkRow) {
|
||||
calls.add(CallLogRow.CreateCallLink)
|
||||
override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
|
||||
val callLogRows = mutableListOf<CallLogRow>()
|
||||
if (length <= 0) {
|
||||
return callLogRows
|
||||
}
|
||||
|
||||
calls.addAll(repository.getCalls(query, filter, start, callLimit).toMutableList())
|
||||
val callLinkStart = if (hasCallLinkRow) 1 else 0
|
||||
val callEventStart = callLinkStart + callLinksCount
|
||||
val clearFilterStart = callEventStart + callEventsCount
|
||||
|
||||
if (calls.size < length && hasFilter) {
|
||||
calls.add(CallLogRow.ClearFilter)
|
||||
var remaining = length
|
||||
if (start < callLinkStart) {
|
||||
callLogRows.add(CallLogRow.CreateCallLink)
|
||||
remaining -= 1
|
||||
}
|
||||
|
||||
return calls
|
||||
if (start < callEventStart && remaining > 0) {
|
||||
val callLinks = repository.getCallLinks(
|
||||
query,
|
||||
filter,
|
||||
start,
|
||||
remaining
|
||||
)
|
||||
|
||||
callLogRows.addAll(callLinks)
|
||||
|
||||
remaining -= callLinks.size
|
||||
}
|
||||
|
||||
if (start < clearFilterStart && remaining > 0) {
|
||||
val callEvents = repository.getCalls(
|
||||
query,
|
||||
filter,
|
||||
start - callLinksCount,
|
||||
remaining
|
||||
)
|
||||
|
||||
callLogRows.addAll(callEvents)
|
||||
|
||||
remaining -= callEvents.size
|
||||
}
|
||||
|
||||
if (start <= clearFilterStart && remaining > 0) {
|
||||
callLogRows.add(CallLogRow.ClearFilter)
|
||||
}
|
||||
|
||||
return callLogRows
|
||||
}
|
||||
|
||||
override fun getKey(data: CallLogRow): CallLogRow.Id = data.id
|
||||
@@ -47,5 +81,7 @@ class CallLogPagedDataSource(
|
||||
interface CallRepository {
|
||||
fun getCallsCount(query: String?, filter: CallLogFilter): Int
|
||||
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
|
||||
fun getCallLinksCount(query: String?, filter: CallLogFilter): Int
|
||||
fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,20 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
|
||||
return SignalDatabase.calls.getCalls(start, length, query, filter)
|
||||
}
|
||||
|
||||
override fun getCallLinksCount(query: String?, filter: CallLogFilter): Int {
|
||||
return when (filter) {
|
||||
CallLogFilter.MISSED -> 0
|
||||
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinksCount(query)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
|
||||
return when (filter) {
|
||||
CallLogFilter.MISSED -> emptyList()
|
||||
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinks(query, start, length)
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllCallEventsRead() {
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
SignalDatabase.messages.markAllCallEventsRead()
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
|
||||
/**
|
||||
* A row to be displayed in the call log
|
||||
@@ -11,6 +18,16 @@ sealed class CallLogRow {
|
||||
|
||||
abstract val id: Id
|
||||
|
||||
/**
|
||||
* A call link with no "active" events.
|
||||
*/
|
||||
data class CallLink(
|
||||
val record: CallLinkTable.CallLink,
|
||||
val recipient: Recipient,
|
||||
val searchQuery: String?,
|
||||
override val id: Id = Id.CallLink(record.roomId)
|
||||
) : CallLogRow()
|
||||
|
||||
/**
|
||||
* An incoming, outgoing, or missed call.
|
||||
*/
|
||||
@@ -20,6 +37,7 @@ sealed class CallLogRow {
|
||||
val date: Long,
|
||||
val groupCallState: GroupCallState,
|
||||
val children: Set<Long>,
|
||||
val searchQuery: String?,
|
||||
override val id: Id = Id.Call(children)
|
||||
) : CallLogRow()
|
||||
|
||||
@@ -36,6 +54,7 @@ sealed class CallLogRow {
|
||||
|
||||
sealed class Id {
|
||||
data class Call(val children: Set<Long>) : Id()
|
||||
data class CallLink(val roomId: CallLinkRoomId) : Id()
|
||||
object ClearFilter : Id()
|
||||
object CreateCallLink : Id()
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ class CallLogViewModel(
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun stageCallDeletion(call: CallLogRow.Call) {
|
||||
fun stageCallDeletion(call: CallLogRow) {
|
||||
callLogStore.state.stagedDeletion?.commit()
|
||||
callLogStore.update {
|
||||
it.copy(
|
||||
|
||||
@@ -302,9 +302,7 @@ public class ComposeText extends EmojiEditText {
|
||||
addTextChangedListener(mentionValidatorWatcher);
|
||||
|
||||
if (FeatureFlags.textFormatting()) {
|
||||
if (FeatureFlags.textFormattingSpoilerSend()) {
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
|
||||
}
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
|
||||
|
||||
addTextChangedListener(new ComposeTextStyleWatcher());
|
||||
|
||||
@@ -323,10 +321,7 @@ public class ComposeText extends EmojiEditText {
|
||||
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
|
||||
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
|
||||
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
|
||||
|
||||
if (FeatureFlags.textFormattingSpoilerSend()) {
|
||||
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
|
||||
}
|
||||
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.animation.Animation;
|
||||
import android.view.animation.AnimationUtils;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
@@ -14,11 +17,14 @@ import androidx.annotation.Nullable;
|
||||
import com.airbnb.lottie.SimpleColorFilter;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public final class ConversationScrollToView extends FrameLayout {
|
||||
|
||||
private final TextView unreadCount;
|
||||
private final ImageView scrollButton;
|
||||
private final Animation inAnimation;
|
||||
private final Animation outAnimation;
|
||||
|
||||
public ConversationScrollToView(@NonNull Context context) {
|
||||
this(context, null);
|
||||
@@ -44,6 +50,20 @@ public final class ConversationScrollToView extends FrameLayout {
|
||||
|
||||
array.recycle();
|
||||
}
|
||||
|
||||
inAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in);
|
||||
outAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out);
|
||||
|
||||
inAnimation.setDuration(100);
|
||||
outAnimation.setDuration(50);
|
||||
}
|
||||
|
||||
public void setShown(boolean isShown) {
|
||||
if (isShown) {
|
||||
ViewUtil.animateIn(this, inAnimation);
|
||||
} else {
|
||||
ViewUtil.animateOut(this, outAnimation, View.INVISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setWallpaperEnabled(boolean hasWallpaper) {
|
||||
|
||||
@@ -8,10 +8,13 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import com.bumptech.glide.request.target.DrawableImageViewTarget;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
|
||||
public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
|
||||
|
||||
private static final String TAG = Log.tag(GlideDrawableListeningTarget.class);
|
||||
|
||||
private final SettableFuture<Boolean> loaded;
|
||||
|
||||
public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) {
|
||||
@@ -21,6 +24,12 @@ public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
|
||||
|
||||
@Override
|
||||
protected void setResource(@Nullable Drawable resource) {
|
||||
if (resource == null) {
|
||||
Log.d(TAG, "Loaded null resource");
|
||||
} else {
|
||||
Log.d(TAG, "Loaded resource of w " + resource.getIntrinsicWidth() + " by h " + resource.getIntrinsicHeight());
|
||||
}
|
||||
|
||||
super.setResource(resource);
|
||||
loaded.set(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.EditText
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* A flavor of [InsetAwareConstraintLayout] that allows "replacing" the keyboard with our
|
||||
* own input fragment.
|
||||
*/
|
||||
class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : InsetAwareConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var inputId: Int? = null
|
||||
private var input: Fragment? = null
|
||||
|
||||
lateinit var fragmentManager: FragmentManager
|
||||
var listener: Listener? = null
|
||||
|
||||
fun showSoftkey(editText: EditText) {
|
||||
ViewUtil.focusAndShowKeyboard(editText)
|
||||
hideInput(resetKeyboardGuideline = false)
|
||||
}
|
||||
|
||||
fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, toggled: (Boolean) -> Unit = { }) {
|
||||
if (fragmentCreator.id == inputId) {
|
||||
hideInput(resetKeyboardGuideline = true)
|
||||
toggled(false)
|
||||
} else {
|
||||
hideInput(resetKeyboardGuideline = false)
|
||||
showInput(fragmentCreator, imeTarget)
|
||||
}
|
||||
}
|
||||
|
||||
fun hideInput() {
|
||||
hideInput(resetKeyboardGuideline = true)
|
||||
}
|
||||
|
||||
private fun showInput(fragmentCreator: FragmentCreator, imeTarget: EditText) {
|
||||
inputId = fragmentCreator.id
|
||||
input = fragmentCreator.create()
|
||||
|
||||
fragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.input_container, input!!)
|
||||
.commit()
|
||||
|
||||
overrideKeyboardGuidelineWithPreviousHeight()
|
||||
ViewUtil.hideKeyboard(context, imeTarget)
|
||||
|
||||
listener?.onInputShown()
|
||||
}
|
||||
|
||||
private fun hideInput(resetKeyboardGuideline: Boolean) {
|
||||
val inputHidden = input != null
|
||||
input?.let {
|
||||
fragmentManager
|
||||
.beginTransaction()
|
||||
.remove(it)
|
||||
.commit()
|
||||
}
|
||||
input = null
|
||||
inputId = null
|
||||
|
||||
if (resetKeyboardGuideline) {
|
||||
resetKeyboardGuideline()
|
||||
} else {
|
||||
clearKeyboardGuidelineOverride()
|
||||
}
|
||||
|
||||
if (inputHidden) {
|
||||
listener?.onInputHidden()
|
||||
}
|
||||
}
|
||||
|
||||
interface FragmentCreator {
|
||||
val id: Int
|
||||
fun create(): Fragment
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onInputShown()
|
||||
fun onInputHidden()
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import android.view.animation.AnimationSet;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.view.animation.TranslateAnimation;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
@@ -30,6 +31,8 @@ import androidx.lifecycle.Observer;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -44,6 +47,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
|
||||
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
|
||||
import org.thoughtcrime.securesms.database.DraftTable;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.Quote;
|
||||
@@ -52,9 +56,11 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -94,8 +100,9 @@ public class InputPanel extends LinearLayout
|
||||
private View recordingContainer;
|
||||
private View recordLockCancel;
|
||||
private ViewGroup composeContainer;
|
||||
private View editMessageLabel;
|
||||
private View editMessageCancel;
|
||||
private ImageView editMessageThumbnail;
|
||||
private View editMessageHeader;
|
||||
|
||||
private MicrophoneRecorderView microphoneRecorderView;
|
||||
private SlideToCancel slideToCancel;
|
||||
@@ -153,8 +160,9 @@ public class InputPanel extends LinearLayout
|
||||
findViewById(R.id.microphone),
|
||||
TimeUnit.HOURS.toSeconds(1),
|
||||
() -> microphoneRecorderView.cancelAction(false));
|
||||
this.editMessageLabel = findViewById(R.id.edit_message);
|
||||
this.editMessageCancel = findViewById(R.id.input_panel_exit_edit_mode);
|
||||
this.editMessageHeader = findViewById(R.id.edit_message_compose_header);
|
||||
this.editMessageThumbnail = findViewById(R.id.edit_message_thumbnail);
|
||||
|
||||
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
|
||||
|
||||
@@ -385,22 +393,43 @@ public class InputPanel extends LinearLayout
|
||||
quoteView.setWallpaperEnabled(enabled);
|
||||
}
|
||||
|
||||
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage messageToEdit, boolean fromDraft) {
|
||||
SpannableString textToEdit = messageToEdit.getDisplayBody(getContext());
|
||||
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage conversationMessageToEdit, boolean fromDraft) {
|
||||
SpannableString textToEdit = conversationMessageToEdit.getDisplayBody(getContext());
|
||||
if (!fromDraft) {
|
||||
composeText.setText(textToEdit);
|
||||
composeText.setSelection(textToEdit.length());
|
||||
}
|
||||
Quote quote = MessageRecordUtil.getQuote(messageToEdit.getMessageRecord());
|
||||
Quote quote = MessageRecordUtil.getQuote(conversationMessageToEdit.getMessageRecord());
|
||||
if (quote == null) {
|
||||
clearQuote();
|
||||
} else {
|
||||
setQuote(glideRequests, quote.getId(), Recipient.resolved(quote.getAuthor()), quote.getDisplayText(), quote.getAttachment(), quote.getQuoteType());
|
||||
}
|
||||
this.messageToEdit = messageToEdit.getMessageRecord();
|
||||
this.messageToEdit = conversationMessageToEdit.getMessageRecord();
|
||||
updateEditModeThumbnail(glideRequests);
|
||||
updateEditModeUi();
|
||||
}
|
||||
|
||||
private void updateEditModeThumbnail(@NonNull GlideRequests glideRequests) {
|
||||
if (messageToEdit instanceof MediaMmsMessageRecord) {
|
||||
MediaMmsMessageRecord mediaEditMessage = (MediaMmsMessageRecord) messageToEdit;
|
||||
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
|
||||
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
|
||||
|
||||
if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
|
||||
editMessageThumbnail.setVisibility(VISIBLE);
|
||||
glideRequests.load(new DecryptableStreamUriLoader.DecryptableUri(imageVideoSlide.getUri()))
|
||||
.centerCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.into(editMessageThumbnail);
|
||||
} else {
|
||||
editMessageThumbnail.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
editMessageThumbnail.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void exitEditMessageMode() {
|
||||
if (messageToEdit != null) {
|
||||
composeText.setText("");
|
||||
@@ -413,13 +442,13 @@ public class InputPanel extends LinearLayout
|
||||
private void updateEditModeUi() {
|
||||
if (inEditMessageMode()) {
|
||||
ViewUtil.focusAndShowKeyboard(composeText);
|
||||
editMessageLabel.setVisibility(View.VISIBLE);
|
||||
editMessageHeader.setVisibility(View.VISIBLE);
|
||||
editMessageCancel.setVisibility(View.VISIBLE);
|
||||
if (listener != null) {
|
||||
listener.onEnterEditMode();
|
||||
}
|
||||
} else {
|
||||
editMessageLabel.setVisibility(View.GONE);
|
||||
editMessageHeader.setVisibility(View.GONE);
|
||||
editMessageCancel.setVisibility(View.GONE);
|
||||
if (listener != null) {
|
||||
listener.onExitEditMode();
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.WindowInsets;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.Guideline;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public class InsetAwareConstraintLayout extends ConstraintLayout {
|
||||
|
||||
private WindowInsetsTypeProvider windowInsetsTypeProvider = WindowInsetsTypeProvider.ALL;
|
||||
private Insets insets;
|
||||
|
||||
public InsetAwareConstraintLayout(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public void setWindowInsetsTypeProvider(@NonNull WindowInsetsTypeProvider windowInsetsTypeProvider) {
|
||||
this.windowInsetsTypeProvider = windowInsetsTypeProvider;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
|
||||
WindowInsetsCompat windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets);
|
||||
Insets newInsets = windowInsetsCompat.getInsets(windowInsetsTypeProvider.getInsetsType());
|
||||
|
||||
applyInsets(newInsets);
|
||||
return super.onApplyWindowInsets(insets);
|
||||
}
|
||||
|
||||
public void applyInsets(@NonNull Insets insets) {
|
||||
Guideline statusBarGuideline = findViewById(R.id.status_bar_guideline);
|
||||
Guideline navigationBarGuideline = findViewById(R.id.navigation_bar_guideline);
|
||||
Guideline parentStartGuideline = findViewById(R.id.parent_start_guideline);
|
||||
Guideline parentEndGuideline = findViewById(R.id.parent_end_guideline);
|
||||
|
||||
if (statusBarGuideline != null) {
|
||||
statusBarGuideline.setGuidelineBegin(insets.top);
|
||||
}
|
||||
|
||||
if (navigationBarGuideline != null) {
|
||||
navigationBarGuideline.setGuidelineEnd(insets.bottom);
|
||||
}
|
||||
|
||||
if (parentStartGuideline != null) {
|
||||
if (ViewUtil.isLtr(this)) {
|
||||
parentStartGuideline.setGuidelineBegin(insets.left);
|
||||
} else {
|
||||
parentStartGuideline.setGuidelineBegin(insets.right);
|
||||
}
|
||||
}
|
||||
|
||||
if (parentEndGuideline != null) {
|
||||
if (ViewUtil.isLtr(this)) {
|
||||
parentEndGuideline.setGuidelineEnd(insets.right);
|
||||
} else {
|
||||
parentEndGuideline.setGuidelineEnd(insets.left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface WindowInsetsTypeProvider {
|
||||
|
||||
WindowInsetsTypeProvider ALL = () ->
|
||||
WindowInsetsCompat.Type.ime() |
|
||||
WindowInsetsCompat.Type.systemBars() |
|
||||
WindowInsetsCompat.Type.displayCutout();
|
||||
|
||||
@WindowInsetsCompat.Type.InsetsType
|
||||
int getInsetsType();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Surface
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.Guideline
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* A specialized [ConstraintLayout] that sets guidelines based on the window insets provided
|
||||
* by the system. For improved backwards-compatibility we must use [ViewCompat] for configuring
|
||||
* the inset change callbacks.
|
||||
*
|
||||
* In portrait mode these are how the guidelines will be configured:
|
||||
*
|
||||
* - [R.id.status_bar_guideline] is set to the bottom of the status bar
|
||||
* - [R.id.navigation_bar_guideline] is set to the top of the navigation bar
|
||||
* - [R.id.parent_start_guideline] is set to the start of the parent
|
||||
* - [R.id.parent_end_guideline] is set to the end of the parent
|
||||
* - [R.id.keyboard_guideline] will be set to the top of the keyboard and will
|
||||
* change as the keyboard is shown or hidden
|
||||
*
|
||||
* In landscape, the spirit of the guidelines are maintained but their names may not
|
||||
* correlated exactly to the inset they are providing.
|
||||
*
|
||||
* These guidelines will only be updated if present in your layout, you can use
|
||||
* `<include layout="@layout/system_ui_guidelines" />` to quickly include them.
|
||||
*/
|
||||
@Suppress("LeakingThis")
|
||||
open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
companion object {
|
||||
private val keyboardType = WindowInsetsCompat.Type.ime()
|
||||
private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||
}
|
||||
|
||||
private val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
|
||||
private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) }
|
||||
private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) }
|
||||
private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) }
|
||||
private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) }
|
||||
|
||||
private val keyboardAnimator = KeyboardInsetAnimator()
|
||||
private val displayMetrics = DisplayMetrics()
|
||||
private var overridingKeyboard: Boolean = false
|
||||
|
||||
init {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsetsCompat ->
|
||||
applyInsets(windowInsets = windowInsetsCompat.getInsets(windowTypes), keyboardInsets = windowInsetsCompat.getInsets(keyboardType))
|
||||
windowInsetsCompat
|
||||
}
|
||||
|
||||
if (attrs != null) {
|
||||
context.withStyledAttributes(attrs, R.styleable.InsetAwareConstraintLayout) {
|
||||
if (getBoolean(R.styleable.InsetAwareConstraintLayout_animateKeyboardChanges, false)) {
|
||||
ViewCompat.setWindowInsetsAnimationCallback(this@InsetAwareConstraintLayout, keyboardAnimator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
|
||||
val isLtr = ViewUtil.isLtr(this)
|
||||
|
||||
statusBarGuideline?.setGuidelineBegin(windowInsets.top)
|
||||
navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom)
|
||||
parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right)
|
||||
parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left)
|
||||
|
||||
if (keyboardInsets.bottom > 0) {
|
||||
setKeyboardHeight(keyboardInsets.bottom)
|
||||
if (!keyboardAnimator.animating) {
|
||||
keyboardGuideline?.setGuidelineEnd(keyboardInsets.bottom)
|
||||
} else {
|
||||
keyboardAnimator.endingGuidelineEnd = keyboardInsets.bottom
|
||||
}
|
||||
} else if (!overridingKeyboard) {
|
||||
if (!keyboardAnimator.animating) {
|
||||
keyboardGuideline?.setGuidelineEnd(windowInsets.bottom)
|
||||
} else {
|
||||
keyboardAnimator.endingGuidelineEnd = windowInsets.bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun overrideKeyboardGuidelineWithPreviousHeight() {
|
||||
overridingKeyboard = true
|
||||
keyboardGuideline?.setGuidelineEnd(getKeyboardHeight())
|
||||
}
|
||||
|
||||
protected fun clearKeyboardGuidelineOverride() {
|
||||
overridingKeyboard = false
|
||||
}
|
||||
|
||||
protected fun resetKeyboardGuideline() {
|
||||
clearKeyboardGuidelineOverride()
|
||||
keyboardGuideline?.setGuidelineEnd(navigationBarGuideline.guidelineEnd)
|
||||
}
|
||||
|
||||
private fun getKeyboardHeight(): Int {
|
||||
val height = if (isLandscape()) {
|
||||
SignalStore.misc().keyboardLandscapeHeight
|
||||
} else {
|
||||
SignalStore.misc().keyboardPortraitHeight
|
||||
}
|
||||
|
||||
return if (height <= 0) {
|
||||
resources.getDimensionPixelSize(R.dimen.default_custom_keyboard_size)
|
||||
} else {
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
private fun setKeyboardHeight(height: Int) {
|
||||
if (isLandscape()) {
|
||||
SignalStore.misc().keyboardLandscapeHeight = height
|
||||
} else {
|
||||
SignalStore.misc().keyboardPortraitHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLandscape(): Boolean {
|
||||
val rotation = getDeviceRotation()
|
||||
return rotation == Surface.ROTATION_90
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun getDeviceRotation(): Int {
|
||||
if (isInEditMode) {
|
||||
return Surface.ROTATION_0
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
context.display?.getRealMetrics(displayMetrics)
|
||||
} else {
|
||||
ServiceUtil.getWindowManager(context).defaultDisplay.getRealMetrics(displayMetrics)
|
||||
}
|
||||
|
||||
return if (displayMetrics.widthPixels > displayMetrics.heightPixels) Surface.ROTATION_90 else Surface.ROTATION_0
|
||||
}
|
||||
|
||||
private val Guideline?.guidelineEnd: Int
|
||||
get() = if (this == null) 0 else (layoutParams as LayoutParams).guideEnd
|
||||
|
||||
/**
|
||||
* Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing.
|
||||
*/
|
||||
private inner class KeyboardInsetAnimator : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
|
||||
|
||||
var animating = false
|
||||
private set
|
||||
|
||||
private var startingGuidelineEnd: Int = 0
|
||||
var endingGuidelineEnd: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
growing = value > startingGuidelineEnd
|
||||
}
|
||||
private var growing: Boolean = false
|
||||
|
||||
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
|
||||
if (overridingKeyboard) {
|
||||
return
|
||||
}
|
||||
|
||||
animating = true
|
||||
startingGuidelineEnd = keyboardGuideline.guidelineEnd
|
||||
}
|
||||
|
||||
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
|
||||
if (overridingKeyboard) {
|
||||
return insets
|
||||
}
|
||||
|
||||
val imeAnimation = runningAnimations.find { it.typeMask and WindowInsetsCompat.Type.ime() != 0 }
|
||||
if (imeAnimation == null) {
|
||||
return insets
|
||||
}
|
||||
|
||||
val estimatedKeyboardHeight: Int = if (growing) {
|
||||
endingGuidelineEnd * imeAnimation.interpolatedFraction
|
||||
} else {
|
||||
startingGuidelineEnd * (1f - imeAnimation.interpolatedFraction)
|
||||
}.toInt()
|
||||
|
||||
if (growing) {
|
||||
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(startingGuidelineEnd))
|
||||
} else {
|
||||
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(endingGuidelineEnd))
|
||||
}
|
||||
|
||||
return insets
|
||||
}
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
||||
if (overridingKeyboard) {
|
||||
return
|
||||
}
|
||||
|
||||
keyboardGuideline?.setGuidelineEnd(endingGuidelineEnd)
|
||||
animating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Build;
|
||||
import android.text.Spannable;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
@@ -31,7 +30,6 @@ import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
@@ -166,7 +164,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
|
||||
setMessageType(messageType);
|
||||
|
||||
bodyView.enableSpoilerFiltering();
|
||||
dismissView.setOnClickListener(view -> setVisibility(GONE));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
@@ -36,7 +37,6 @@ class ScrollToPositionDelegate private constructor(
|
||||
private val EMPTY = ScrollToPositionRequest(
|
||||
position = NO_POSITION,
|
||||
smooth = true,
|
||||
awaitLayout = true,
|
||||
scrollStrategy = DefaultScrollStrategy
|
||||
)
|
||||
}
|
||||
@@ -57,14 +57,8 @@ class ScrollToPositionDelegate private constructor(
|
||||
.filter { it.position >= 0 && canJumpToPosition(it.position) }
|
||||
.map { it.copy(position = mapToTruePosition(it.position)) }
|
||||
.subscribeBy(onNext = { position ->
|
||||
if (position.awaitLayout) {
|
||||
recyclerView.doAfterNextLayout {
|
||||
handleScrollPositionRequest(position, recyclerView)
|
||||
}
|
||||
} else {
|
||||
recyclerView.post {
|
||||
handleScrollPositionRequest(position, recyclerView)
|
||||
}
|
||||
recyclerView.doAfterNextLayout {
|
||||
handleScrollPositionRequest(position, recyclerView)
|
||||
}
|
||||
|
||||
if (!(recyclerView.isLayoutRequested || recyclerView.isInLayout)) {
|
||||
@@ -78,21 +72,21 @@ class ScrollToPositionDelegate private constructor(
|
||||
*
|
||||
* @param position The desired position to jump to. -1 to clear the current request.
|
||||
* @param smooth Whether a smooth scroll will be attempted. Only done if we are within a certain distance.
|
||||
* @param awaitLayout Whether this scroll should await for the next layout to complete before being attempted.
|
||||
* @param scrollStrategy See [ScrollStrategy]
|
||||
*/
|
||||
@AnyThread
|
||||
fun requestScrollPosition(
|
||||
position: Int,
|
||||
smooth: Boolean = true,
|
||||
awaitLayout: Boolean = true,
|
||||
scrollStrategy: ScrollStrategy = DefaultScrollStrategy
|
||||
) {
|
||||
scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth, awaitLayout, scrollStrategy))
|
||||
scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth, scrollStrategy))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the scroll position to 0
|
||||
*/
|
||||
@AnyThread
|
||||
fun resetScrollPosition() {
|
||||
requestScrollPosition(0, true)
|
||||
}
|
||||
@@ -100,6 +94,7 @@ class ScrollToPositionDelegate private constructor(
|
||||
/**
|
||||
* This should be called every time a list is submitted to the RecyclerView's adapter.
|
||||
*/
|
||||
@AnyThread
|
||||
fun notifyListCommitted() {
|
||||
listCommitted.onNext(Unit)
|
||||
}
|
||||
@@ -135,7 +130,6 @@ class ScrollToPositionDelegate private constructor(
|
||||
private data class ScrollToPositionRequest(
|
||||
val position: Int,
|
||||
val smooth: Boolean,
|
||||
val awaitLayout: Boolean,
|
||||
val scrollStrategy: ScrollStrategy
|
||||
)
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
.show(items)
|
||||
}
|
||||
|
||||
interface SendTypeChangedListener {
|
||||
fun interface SendTypeChangedListener {
|
||||
fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Paint.FontMetricsInt;
|
||||
import android.graphics.drawable.Drawable;
|
||||
@@ -55,6 +56,10 @@ public class EmojiSpan extends AnimatingImageSpan {
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
|
||||
if (paint.getColor() == Color.TRANSPARENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
int height = bottom - top;
|
||||
int centeringMargin = (height - size) / 2;
|
||||
int adjustedMargin = (int) (centeringMargin * SHIFT_FACTOR);
|
||||
|
||||
@@ -68,11 +68,9 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
private TextDirectionHeuristic textDirection;
|
||||
private boolean isJumbomoji;
|
||||
private boolean forceJumboEmoji;
|
||||
private boolean isInOnDraw;
|
||||
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private final SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private final SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
|
||||
public EmojiTextView(Context context) {
|
||||
this(context, null);
|
||||
@@ -113,15 +111,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
setText(getText());
|
||||
}
|
||||
|
||||
public void enableSpoilerFiltering() {
|
||||
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory(() -> isInOnDraw);
|
||||
setSpannableFactory(spoilerFilteringSpannableFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
isInOnDraw = true;
|
||||
|
||||
boolean hasSpannedText = getText() instanceof Spanned;
|
||||
boolean hasLayout = getLayout() != null;
|
||||
|
||||
@@ -134,8 +125,6 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
if (hasSpannedText && !hasLayout && getLayout() != null) {
|
||||
drawSpecialRenderers(canvas, null, spoilerRendererDelegate);
|
||||
}
|
||||
|
||||
isInOnDraw = false;
|
||||
}
|
||||
|
||||
private void drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
|
||||
@@ -187,9 +176,6 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji));
|
||||
}
|
||||
|
||||
if (spoilerFilteringSpannableFactory != null) {
|
||||
textToSet = spoilerFilteringSpannableFactory.wrap(textToSet);
|
||||
}
|
||||
super.setText(textToSet, BufferType.SPANNABLE);
|
||||
|
||||
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
|
||||
@@ -333,11 +319,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
newTextToSet = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
|
||||
}
|
||||
|
||||
if (spoilerFilteringSpannableFactory != null) {
|
||||
spoilerFilteringSpannableFactory.wrap(newTextToSet);
|
||||
}
|
||||
|
||||
super.setText(newContent, BufferType.SPANNABLE);
|
||||
super.setText(newTextToSet, BufferType.SPANNABLE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +348,9 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
newContent.append(getText().subSequence(0, overflowStart).toString())
|
||||
.append(ellipsized.subSequence(0, ellipsized.length()).toString());
|
||||
|
||||
TextUtils.copySpansFrom(getText(newContent.length() - 1), 0, newContent.length() - 1, Object.class, newContent, 0);
|
||||
if (newContent.length() > 0) {
|
||||
TextUtils.copySpansFrom(getText(newContent.length() - 1), 0, newContent.length() - 1, Object.class, newContent, 0);
|
||||
}
|
||||
|
||||
newContent.append(Optional.ofNullable(overflowText).orElse(""));
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.text.Spannable
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
@@ -21,8 +20,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
private var bufferType: BufferType? = null
|
||||
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
|
||||
private val spoilerRendererDelegate: SpoilerRendererDelegate
|
||||
private var spoilerFilteringSpannableFactory: SpoilerFilteringSpannableFactory? = null
|
||||
private var isInOnDraw: Boolean = false
|
||||
|
||||
init {
|
||||
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
|
||||
@@ -30,8 +27,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
isInOnDraw = true
|
||||
|
||||
if (text is Spanned && layout != null) {
|
||||
val checkpoint = canvas.save()
|
||||
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
|
||||
@@ -41,9 +36,8 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
canvas.restoreToCount(checkpoint)
|
||||
}
|
||||
}
|
||||
super.onDraw(canvas)
|
||||
|
||||
isInOnDraw = false
|
||||
super.onDraw(canvas)
|
||||
}
|
||||
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
@@ -69,10 +63,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
|
||||
}
|
||||
|
||||
if (newContent is Spannable && spoilerFilteringSpannableFactory != null) {
|
||||
newContent = spoilerFilteringSpannableFactory!!.wrap(newContent)
|
||||
}
|
||||
|
||||
bufferType = BufferType.SPANNABLE
|
||||
super.setText(newContent, type)
|
||||
}
|
||||
@@ -86,9 +76,4 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableSpoilerFiltering() {
|
||||
spoilerFilteringSpannableFactory = SpoilerFilteringSpannableFactory { isInOnDraw }
|
||||
setSpannableFactory(spoilerFilteringSpannableFactory!!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.text.Spannable
|
||||
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable
|
||||
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable.InOnDrawProvider
|
||||
|
||||
/**
|
||||
* Spannable factory used to help ensure spans are copied/maintained properly through the
|
||||
* Android text handling system.
|
||||
*
|
||||
* @param inOnDraw Used by [SpoilerFilteringSpannable] to remove spans when being called from onDraw
|
||||
*/
|
||||
class SpoilerFilteringSpannableFactory(private val inOnDraw: InOnDrawProvider) : Spannable.Factory() {
|
||||
override fun newSpannable(source: CharSequence): Spannable {
|
||||
return wrap(super.newSpannable(source))
|
||||
}
|
||||
|
||||
fun wrap(source: Spannable): SpoilerFilteringSpannable {
|
||||
return SpoilerFilteringSpannable(source, inOnDraw)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import com.google.zxing.common.BitMatrix;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.SquareImageView;
|
||||
import org.thoughtcrime.securesms.qr.QrCode;
|
||||
import org.thoughtcrime.securesms.qr.QrCodeUtil;
|
||||
|
||||
/**
|
||||
* Generates a bitmap asynchronously for the supplied {@link BitMatrix} data and displays it.
|
||||
@@ -59,7 +59,7 @@ public class QrView extends SquareImageView {
|
||||
}
|
||||
|
||||
public void setQrText(@Nullable String text) {
|
||||
setQrBitmap(QrCode.create(text, foregroundColor, backgroundColor));
|
||||
setQrBitmap(QrCodeUtil.create(text, foregroundColor, backgroundColor));
|
||||
}
|
||||
|
||||
private void setQrBitmap(@Nullable Bitmap qrBitmap) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.components.reminder;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
@@ -9,12 +11,14 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
public class UnauthorizedReminder extends Reminder {
|
||||
|
||||
public UnauthorizedReminder(final Context context) {
|
||||
super(context.getString(R.string.UnauthorizedReminder_device_no_longer_registered),
|
||||
super(null,
|
||||
context.getString(R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device));
|
||||
|
||||
setOkListener(v -> {
|
||||
context.startActivity(RegistrationNavigationActivity.newIntentForReRegistration(context));
|
||||
});
|
||||
|
||||
addAction(new Action(context.getString(R.string.UnauthorizedReminder_reregister_action), R.id.reminder_action_re_register));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -22,6 +26,11 @@ public class UnauthorizedReminder extends Reminder {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Importance getImportance() {
|
||||
return Importance.ERROR;
|
||||
}
|
||||
|
||||
public static boolean isEligible(Context context) {
|
||||
return TextSecurePreferences.isUnauthorizedReceived(context);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : Ma
|
||||
val icon = model.icon?.resolve(context)
|
||||
iconView.setImageDrawable(icon)
|
||||
iconView.visible = icon != null
|
||||
iconView.alpha = if (model.isEnabled) 1f else 0.5f
|
||||
|
||||
val iconEnd = model.iconEnd?.resolve(context)
|
||||
iconEndView?.setImageDrawable(iconEnd)
|
||||
@@ -211,22 +212,21 @@ class SwitchPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SwitchPr
|
||||
switchWidget.setOnCheckedChangeListener(null)
|
||||
|
||||
switchWidget.isChecked = model.isChecked
|
||||
switchWidget.isEnabled = model.isEnabled
|
||||
|
||||
switchWidget.setOnCheckedChangeListener { _, _ ->
|
||||
model.onClick()
|
||||
}
|
||||
|
||||
itemView.setOnClickListener {
|
||||
model.onClick()
|
||||
}
|
||||
|
||||
if (payload.contains(SwitchPreference.PAYLOAD_CHECKED)) {
|
||||
return
|
||||
}
|
||||
|
||||
super.bind(model)
|
||||
|
||||
switchWidget.isEnabled = model.isEnabled
|
||||
|
||||
itemView.setOnClickListener {
|
||||
model.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder
|
||||
import org.thoughtcrime.securesms.components.reminder.Reminder
|
||||
import org.thoughtcrime.securesms.components.reminder.ReminderView
|
||||
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
@@ -15,20 +25,37 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.views.Stub
|
||||
|
||||
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
|
||||
class AppSettingsFragment : DSLSettingsFragment(
|
||||
titleId = R.string.text_secure_normal__menu_settings,
|
||||
layoutId = R.layout.dsl_settings_fragment_with_reminder
|
||||
) {
|
||||
|
||||
private val viewModel: AppSettingsViewModel by viewModels()
|
||||
|
||||
private lateinit var reminderView: Stub<ReminderView>
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
reminderView = ViewUtil.findStubById(view, R.id.reminder_stub)
|
||||
|
||||
updateReminders()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
|
||||
adapter.registerFactory(PaymentsPreference::class.java, LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
|
||||
@@ -39,17 +66,78 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onEvent(event: ReminderUpdateEvent?) {
|
||||
updateReminders()
|
||||
}
|
||||
|
||||
private fun updateReminders() {
|
||||
if (ExpiredBuildReminder.isEligible()) {
|
||||
showReminder(ExpiredBuildReminder(context))
|
||||
} else if (UnauthorizedReminder.isEligible(context)) {
|
||||
showReminder(UnauthorizedReminder(context))
|
||||
} else {
|
||||
hideReminders()
|
||||
}
|
||||
viewModel.refreshDeprecatedOrUnregistered()
|
||||
}
|
||||
|
||||
private fun showReminder(reminder: Reminder) {
|
||||
if (!reminderView.resolved()) {
|
||||
reminderView.get().addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
|
||||
recyclerView?.setPadding(0, bottom - top, 0, 0)
|
||||
}
|
||||
recyclerView?.clipToPadding = false
|
||||
}
|
||||
reminderView.get().showReminder(reminder)
|
||||
reminderView.get().setOnActionClickListener { reminderActionId: Int -> this.handleReminderAction(reminderActionId) }
|
||||
}
|
||||
|
||||
private fun hideReminders() {
|
||||
if (reminderView.resolved()) {
|
||||
reminderView.get().hide()
|
||||
recyclerView?.clipToPadding = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReminderAction(@IdRes reminderActionId: Int) {
|
||||
when (reminderActionId) {
|
||||
R.id.reminder_action_update_now -> {
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
|
||||
}
|
||||
R.id.reminder_action_re_register -> {
|
||||
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refreshExpiredGiftBadge()
|
||||
EventBus.getDefault().register(this)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
EventBus.getDefault().unregister(this)
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(
|
||||
BioPreference(state.self) {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
|
||||
}
|
||||
BioPreference(
|
||||
recipient = state.self,
|
||||
onRowClicked = {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
|
||||
},
|
||||
onQrButtonClicked = {
|
||||
if (Recipient.self().getUsername().isPresent()) {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
|
||||
} else {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
clickPref(
|
||||
@@ -65,7 +153,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_devices_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_deviceActivity)
|
||||
}
|
||||
},
|
||||
isEnabled = state.isDeprecatedOrUnregistered()
|
||||
)
|
||||
|
||||
if (state.allowUserToGoToDonationManagementScreen) {
|
||||
@@ -101,7 +190,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_chat_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
|
||||
}
|
||||
},
|
||||
isEnabled = state.isDeprecatedOrUnregistered()
|
||||
)
|
||||
|
||||
clickPref(
|
||||
@@ -109,7 +199,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_stories_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
|
||||
}
|
||||
},
|
||||
isEnabled = state.isDeprecatedOrUnregistered()
|
||||
)
|
||||
|
||||
clickPref(
|
||||
@@ -117,7 +208,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_bell_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
|
||||
}
|
||||
},
|
||||
isEnabled = state.isDeprecatedOrUnregistered()
|
||||
)
|
||||
|
||||
clickPref(
|
||||
@@ -125,7 +217,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_lock_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
|
||||
}
|
||||
},
|
||||
isEnabled = state.isDeprecatedOrUnregistered()
|
||||
)
|
||||
|
||||
clickPref(
|
||||
@@ -216,7 +309,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
}
|
||||
}
|
||||
|
||||
private class BioPreference(val recipient: Recipient, val onClick: () -> Unit) : PreferenceModel<BioPreference>() {
|
||||
private class BioPreference(val recipient: Recipient, val onRowClicked: () -> Unit, val onQrButtonClicked: () -> Unit) : PreferenceModel<BioPreference>() {
|
||||
override fun areContentsTheSame(newItem: BioPreference): Boolean {
|
||||
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
|
||||
}
|
||||
@@ -229,13 +322,18 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
private class BioPreferenceViewHolder(itemView: View) : PreferenceViewHolder<BioPreference>(itemView) {
|
||||
|
||||
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon)
|
||||
private val aboutView: TextView = itemView.findViewById(R.id.about)
|
||||
private val aboutView: EmojiTextView = itemView.findViewById(R.id.about)
|
||||
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
private val qrButton: View = itemView.findViewById(R.id.qr_button)
|
||||
|
||||
init {
|
||||
aboutView.setOverflowText(" ")
|
||||
}
|
||||
|
||||
override fun bind(model: BioPreference) {
|
||||
super.bind(model)
|
||||
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
itemView.setOnClickListener { model.onRowClicked() }
|
||||
|
||||
titleView.text = model.recipient.profileName.toString()
|
||||
summaryView.text = PhoneNumberFormatter.prettyPrint(model.recipient.requireE164())
|
||||
@@ -246,6 +344,14 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
summaryView.visibility = View.VISIBLE
|
||||
avatarView.visibility = View.VISIBLE
|
||||
|
||||
if (FeatureFlags.usernames()) {
|
||||
qrButton.visibility = View.VISIBLE
|
||||
qrButton.isClickable = true
|
||||
qrButton.setOnClickListener { model.onQrButtonClicked() }
|
||||
} else {
|
||||
qrButton.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (model.recipient.combinedAboutAndEmoji != null) {
|
||||
aboutView.text = model.recipient.combinedAboutAndEmoji
|
||||
aboutView.visibility = View.VISIBLE
|
||||
|
||||
@@ -6,5 +6,11 @@ data class AppSettingsState(
|
||||
val self: Recipient,
|
||||
val unreadPaymentsCount: Int,
|
||||
val hasExpiredGiftBadge: Boolean,
|
||||
val allowUserToGoToDonationManagementScreen: Boolean
|
||||
)
|
||||
val allowUserToGoToDonationManagementScreen: Boolean,
|
||||
val userUnregistered: Boolean,
|
||||
val clientDeprecated: Boolean
|
||||
) {
|
||||
fun isDeprecatedOrUnregistered(): Boolean {
|
||||
return !(userUnregistered || clientDeprecated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class AppSettingsViewModel(
|
||||
@@ -22,7 +23,9 @@ class AppSettingsViewModel(
|
||||
Recipient.self(),
|
||||
0,
|
||||
SignalStore.donationsValues().getExpiredGiftBadge() != null,
|
||||
SignalStore.donationsValues().isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
SignalStore.donationsValues().isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable(),
|
||||
TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()),
|
||||
SignalStore.misc().isClientDeprecated
|
||||
)
|
||||
)
|
||||
|
||||
@@ -50,6 +53,10 @@ class AppSettingsViewModel(
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun refreshDeprecatedOrUnregistered() {
|
||||
store.update { it.copy(clientDeprecated = SignalStore.misc().isClientDeprecated, userUnregistered = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication())) }
|
||||
}
|
||||
|
||||
fun refreshExpiredGiftBadge() {
|
||||
store.update { it.copy(hasExpiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge() != null) }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.account
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.graphics.Typeface
|
||||
import android.text.InputType
|
||||
@@ -9,6 +10,7 @@ import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.autofill.HintConstants
|
||||
import androidx.core.app.DialogCompat
|
||||
@@ -24,15 +26,19 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.PinHashing
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
|
||||
import org.thoughtcrime.securesms.lock.v2.KbsConstants
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
|
||||
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil
|
||||
|
||||
class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFragment__account) {
|
||||
|
||||
@@ -64,6 +70,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
@Suppress("DEPRECATION")
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(if (state.hasPin) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin),
|
||||
isEnabled = state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
if (state.hasPin) {
|
||||
startActivityForResult(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN)
|
||||
@@ -77,7 +84,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
title = DSLSettingsText.from(R.string.preferences_app_protection__pin_reminders),
|
||||
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__youll_be_asked_less_frequently),
|
||||
isChecked = state.hasPin && state.pinRemindersEnabled,
|
||||
isEnabled = state.hasPin,
|
||||
isEnabled = state.hasPin && state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
setPinRemindersEnabled(!state.pinRemindersEnabled)
|
||||
}
|
||||
@@ -87,7 +94,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
title = DSLSettingsText.from(R.string.preferences_app_protection__registration_lock),
|
||||
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__require_your_signal_pin),
|
||||
isChecked = state.registrationLockEnabled,
|
||||
isEnabled = state.hasPin,
|
||||
isEnabled = state.hasPin && state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
setRegistrationLockEnabled(!state.registrationLockEnabled)
|
||||
}
|
||||
@@ -95,6 +102,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__advanced_pin_settings),
|
||||
isEnabled = state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity)
|
||||
}
|
||||
@@ -107,6 +115,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
if (SignalStore.account().isRegistered) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
|
||||
isEnabled = state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
|
||||
}
|
||||
@@ -116,6 +125,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
|
||||
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),
|
||||
isEnabled = state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
|
||||
}
|
||||
@@ -123,13 +133,49 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.AccountSettingsFragment__request_account_data),
|
||||
isEnabled = state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_exportAccountFragment)
|
||||
}
|
||||
)
|
||||
|
||||
if (!state.isDeprecatedOrUnregistered()) {
|
||||
if (state.clientDeprecated) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_account_update_signal),
|
||||
onClick = {
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
|
||||
}
|
||||
)
|
||||
} else if (state.userUnregistered) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_account_reregister),
|
||||
onClick = {
|
||||
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_account_delete_all_data, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.preferences_account_delete_all_data_confirmation_title)
|
||||
.setMessage(R.string.preferences_account_delete_all_data_confirmation_message)
|
||||
.setPositiveButton(R.string.preferences_account_delete_all_data_confirmation_proceed) { _: DialogInterface, _: Int ->
|
||||
if (!ServiceUtil.getActivityManager(ApplicationDependencies.getApplication()).clearApplicationUserData()) {
|
||||
Toast.makeText(requireContext(), R.string.preferences_account_delete_all_data_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.preferences_account_delete_all_data_confirmation_cancel, null)
|
||||
.show()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
|
||||
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), if (state.isDeprecatedOrUnregistered()) R.color.signal_alert_primary else R.color.signal_alert_primary_50)),
|
||||
isEnabled = state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_deleteAccountFragment)
|
||||
}
|
||||
@@ -201,7 +247,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
pinEditText.typeface = Typeface.DEFAULT
|
||||
turnOffButton.setOnClickListener {
|
||||
val pin = pinEditText.text.toString()
|
||||
val correct = PinHashing.verifyLocalPinHash(SignalStore.kbsValues().localPinHash!!, pin)
|
||||
val correct = PinHashUtil.verifyLocalPinHash(SignalStore.kbsValues().localPinHash!!, pin)
|
||||
if (correct) {
|
||||
SignalStore.pinValues().setPinRemindersEnabled(false)
|
||||
viewModel.refreshState()
|
||||
|
||||
@@ -3,5 +3,11 @@ package org.thoughtcrime.securesms.components.settings.app.account
|
||||
data class AccountSettingsState(
|
||||
val hasPin: Boolean,
|
||||
val pinRemindersEnabled: Boolean,
|
||||
val registrationLockEnabled: Boolean
|
||||
)
|
||||
val registrationLockEnabled: Boolean,
|
||||
val userUnregistered: Boolean,
|
||||
val clientDeprecated: Boolean
|
||||
) {
|
||||
fun isDeprecatedOrUnregistered(): Boolean {
|
||||
return !(userUnregistered || clientDeprecated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.components.settings.app.account
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class AccountSettingsViewModel : ViewModel() {
|
||||
@@ -18,7 +20,9 @@ class AccountSettingsViewModel : ViewModel() {
|
||||
return AccountSettingsState(
|
||||
hasPin = SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut(),
|
||||
pinRemindersEnabled = SignalStore.pinValues().arePinRemindersEnabled(),
|
||||
registrationLockEnabled = SignalStore.kbsValues().isV2RegistrationLockEnabled
|
||||
registrationLockEnabled = SignalStore.kbsValues().isV2RegistrationLockEnabled,
|
||||
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()),
|
||||
clientDeprecated = SignalStore.misc().isClientDeprecated
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance
|
||||
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import org.signal.core.util.concurrent.observe
|
||||
@@ -67,6 +68,15 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app
|
||||
}
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__app_icon),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_appearanceSettings_to_appIconActivity)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_chats__message_text_size),
|
||||
listItems = messageFontSizeLabels,
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconPreset
|
||||
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconUtility
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
class AppIconSelectionFragment : ComposeFragment() {
|
||||
private lateinit var appIconUtility: AppIconUtility
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
appIconUtility = AppIconUtility(context)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.preferences__app_icon),
|
||||
onNavigationClick = {
|
||||
findNavController().popBackStack()
|
||||
},
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { contentPadding: PaddingValues ->
|
||||
IconSelectionScreen(appIconUtility.currentAppIcon, ::updateAppIcon, ::openLearnMore, Modifier.padding(contentPadding))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAppIcon(preset: AppIconPreset) {
|
||||
if (!appIconUtility.isCurrentlySelected(preset)) {
|
||||
appIconUtility.setNewAppIcon(preset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openLearnMore() {
|
||||
findNavController().safeNavigate(R.id.action_appIconSelectionFragment_to_appIconTutorialFragment)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(AppIconSelectionFragment::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private const val LEARN_MORE_TAG = "learn_more"
|
||||
private const val URL_TAG = "URL"
|
||||
private const val COLUMN_COUNT = 4
|
||||
|
||||
/**
|
||||
* Screen allowing the user to view all the possible icon and select a new one to use.
|
||||
*/
|
||||
@Composable
|
||||
fun IconSelectionScreen(activeIcon: AppIconPreset, onItemConfirmed: (AppIconPreset) -> Unit, onWarningClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var showDialog: Boolean by remember { mutableStateOf(false) }
|
||||
var pendingIcon: AppIconPreset by remember {
|
||||
mutableStateOf(activeIcon)
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ChangeIconDialog(
|
||||
pendingIcon = pendingIcon,
|
||||
onConfirm = {
|
||||
onItemConfirmed(pendingIcon)
|
||||
showDialog = false
|
||||
},
|
||||
onDismiss = {
|
||||
pendingIcon = activeIcon
|
||||
showDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
CaveatWarning(
|
||||
onClick = onWarningClick,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
enumValues<AppIconPreset>().toList().chunked(COLUMN_COUNT).map { it.toImmutableList() }.forEach { items ->
|
||||
IconRow(
|
||||
presets = items,
|
||||
isSelected = { it == pendingIcon },
|
||||
onItemClick = {
|
||||
pendingIcon = it
|
||||
showDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChangeIconDialog(pendingIcon: AppIconPreset, onConfirm: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier) {
|
||||
AlertDialog(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_cancel))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
AppIcon(preset = pendingIcon, isSelected = false, onClick = {})
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.preferences__app_icon_dialog_title, stringResource(id = pendingIcon.labelResId)),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.preferences__app_icon_dialog_description),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable rendering the one row of icons that the user may choose from.
|
||||
*/
|
||||
@Composable
|
||||
fun IconRow(presets: ImmutableList<AppIconPreset>, isSelected: (AppIconPreset) -> Boolean, onItemClick: (AppIconPreset) -> Unit, modifier: Modifier = Modifier) {
|
||||
Row(modifier = modifier.fillMaxWidth()) {
|
||||
presets.forEach { preset ->
|
||||
val currentlySelected = isSelected(preset)
|
||||
IconGridElement(
|
||||
preset = preset,
|
||||
isSelected = currentlySelected,
|
||||
onClickHandler = {
|
||||
if (!currentlySelected) {
|
||||
onItemClick(preset)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(vertical = 18.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable rendering an individual icon inside that grid, including the black border of the selected icon.
|
||||
*/
|
||||
@Composable
|
||||
fun IconGridElement(preset: AppIconPreset, isSelected: Boolean, onClickHandler: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val boxModifier = Modifier.size(64.dp)
|
||||
Box(
|
||||
modifier = if (isSelected) boxModifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else boxModifier
|
||||
) {
|
||||
AppIcon(preset = preset, isSelected = isSelected, onClickHandler, modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = stringResource(id = preset.labelResId),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable rendering the icon and optionally a border, to indicate selection.
|
||||
*/
|
||||
@Composable
|
||||
fun AppIcon(preset: AppIconPreset, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val bitmapSize by animateFloatAsState(
|
||||
targetValue = if (isSelected) 48f else 64f,
|
||||
label = "Icon Size",
|
||||
animationSpec = tween(durationMillis = 150, easing = CubicBezierEasing(0.17f, 0.17f, 0f, 1f))
|
||||
)
|
||||
val imageModifier = modifier
|
||||
.size(bitmapSize.dp)
|
||||
.graphicsLayer(
|
||||
shape = CircleShape,
|
||||
shadowElevation = if (isSelected) 4f else 8f,
|
||||
clip = true
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
Image(
|
||||
painterResource(id = preset.iconPreviewResId),
|
||||
contentDescription = stringResource(id = preset.labelResId),
|
||||
modifier = imageModifier
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A clickable "learn more" block of text.
|
||||
*/
|
||||
@Composable
|
||||
fun CaveatWarning(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val learnMoreString = stringResource(R.string.preferences__app_icon_learn_more)
|
||||
val completeString = stringResource(R.string.preferences__app_icon_warning_learn_more)
|
||||
val learnMoreStartIndex = completeString.indexOf(learnMoreString).coerceAtLeast(0)
|
||||
val learnMoreEndIndex = learnMoreStartIndex + learnMoreString.length
|
||||
val doesStringEndWithLearnMore = learnMoreEndIndex >= completeString.lastIndex
|
||||
val annotatedText = buildAnnotatedString {
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
append(completeString.substring(0, learnMoreStartIndex))
|
||||
}
|
||||
pushStringAnnotation(
|
||||
tag = URL_TAG,
|
||||
annotation = LEARN_MORE_TAG
|
||||
)
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
append(learnMoreString)
|
||||
}
|
||||
pop()
|
||||
if (!doesStringEndWithLearnMore) {
|
||||
append(completeString.substring(learnMoreEndIndex, completeString.lastIndex))
|
||||
}
|
||||
}
|
||||
ClickableText(
|
||||
text = annotatedText,
|
||||
onClick = { onClick() },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme")
|
||||
@Composable
|
||||
private fun MainScreenPreviewLight() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
IconSelectionScreen(AppIconPreset.DEFAULT, onItemConfirmed = {}, onWarningClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Dark Theme")
|
||||
@Composable
|
||||
private fun MainScreenPreviewDark() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface {
|
||||
IconSelectionScreen(AppIconPreset.DEFAULT, onItemConfirmed = {}, onWarningClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
class AppIconTutorialFragment : ComposeFragment() {
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
onNavigationClick = {
|
||||
findNavController().popBackStack()
|
||||
},
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { contentPadding: PaddingValues ->
|
||||
TutorialScreen(Modifier.padding(contentPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TutorialScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.align(Alignment.Center)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val borderShape = RoundedCornerShape(12.dp)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.preferences__app_icon_warning),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(borderShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.app_icon_tutorial_apps_homescreen),
|
||||
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 328.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.preferences__app_icon_notification_warning),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(borderShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.app_icon_tutorial_notification),
|
||||
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 328.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun TutorialScreenPreview() {
|
||||
TutorialScreen()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(AppIconTutorialFragment::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
enum class AppIconPreset(private val componentName: String, @DrawableRes val iconPreviewResId: Int, @StringRes val labelResId: Int) {
|
||||
DEFAULT(".RoutingActivity", R.drawable.ic_app_icon_default_top_preview, R.string.app_name),
|
||||
WHITE(".RoutingActivityAltWhite", R.drawable.ic_app_icon_signal_white_top_preview, R.string.app_name),
|
||||
COLOR(".RoutingActivityAltColor", R.drawable.ic_app_icon_signal_color_top_preview, R.string.app_name),
|
||||
DARK(".RoutingActivityAltDark", R.drawable.ic_app_icon_signal_dark_top_preview, R.string.app_name),
|
||||
DARK_VARIANT(".RoutingActivityAltDarkVariant", R.drawable.ic_app_icon_signal_dark_variant_top_preview, R.string.app_name),
|
||||
CHAT(".RoutingActivityAltChat", R.drawable.ic_app_icon_chat_top_preview, R.string.app_name),
|
||||
BUBBLES(".RoutingActivityAltBubbles", R.drawable.ic_app_icon_bubbles_top_preview, R.string.app_name),
|
||||
YELLOW(".RoutingActivityAltYellow", R.drawable.ic_app_icon_yellow_top_preview, R.string.app_name),
|
||||
NEWS(".RoutingActivityAltNews", R.drawable.ic_app_icon_news_top_preview, R.string.app_icon_label_news),
|
||||
NOTES(".RoutingActivityAltNotes", R.drawable.ic_app_icon_notes_top_preview, R.string.app_icon_label_notes),
|
||||
WEATHER(".RoutingActivityAltWeather", R.drawable.ic_app_icon_weather_top_preview, R.string.app_icon_label_weather),
|
||||
WAVES(".RoutingActivityAltWaves", R.drawable.ic_app_icon_waves_top_preview, R.string.app_icon_label_waves);
|
||||
|
||||
fun getComponentName(context: Context): ComponentName {
|
||||
val applicationContext = context.applicationContext
|
||||
return ComponentName(applicationContext, "org.thoughtcrime.securesms" + componentName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class AppIconUtility(context: Context) {
|
||||
private val applicationContext: Context = context.applicationContext
|
||||
private val pm = applicationContext.packageManager
|
||||
|
||||
val currentAppIcon by lazy { readCurrentAppIconFromPackageManager() }
|
||||
|
||||
fun isCurrentlySelected(preset: AppIconPreset): Boolean {
|
||||
return preset == currentAppIcon
|
||||
}
|
||||
|
||||
fun currentAppIconComponentName(): ComponentName {
|
||||
return currentAppIcon.getComponentName(applicationContext)
|
||||
}
|
||||
|
||||
fun setNewAppIcon(desiredAppIcon: AppIconPreset) {
|
||||
Log.d(TAG, "Setting new app icon.")
|
||||
pm.setComponentEnabledSetting(desiredAppIcon.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||
Log.d(TAG, "${desiredAppIcon.name} enabled.")
|
||||
val previousAppIcon = currentAppIcon
|
||||
pm.setComponentEnabledSetting(previousAppIcon.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
Log.d(TAG, "${previousAppIcon.name} disabled.")
|
||||
enumValues<AppIconPreset>().filterNot { it == desiredAppIcon || it == previousAppIcon }.forEach {
|
||||
pm.setComponentEnabledSetting(it.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
Log.d(TAG, "${it.name} disabled.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCurrentAppIconFromPackageManager(): AppIconPreset {
|
||||
val activeIcon = enumValues<AppIconPreset>().firstOrNull {
|
||||
val componentName = it.getComponentName(applicationContext)
|
||||
val componentEnabledSetting = pm.getComponentEnabledSetting(componentName)
|
||||
|
||||
Log.d(TAG, "Found $componentName with state of $componentEnabledSetting")
|
||||
if (it == AppIconPreset.DEFAULT && componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) {
|
||||
return it
|
||||
}
|
||||
|
||||
componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
}
|
||||
|
||||
return if (activeIcon == null) {
|
||||
setNewAppIcon(AppIconPreset.DEFAULT)
|
||||
AppIconPreset.DEFAULT
|
||||
} else {
|
||||
activeIcon
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AppIconUtility"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.databinding.ChooseNavigationBarStyleFragmentBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
|
||||
/**
|
||||
* Allows the user to choose between a compact and full-sized navigation bar.
|
||||
@@ -75,11 +74,7 @@ class ChooseNavigationBarStyleFragment : DialogFragment(R.layout.choose_navigati
|
||||
companion object {
|
||||
@DrawableRes
|
||||
fun getImageResourceId(isCompact: Boolean): Int {
|
||||
return if (FeatureFlags.callsTab()) {
|
||||
ThreeButtons.getImageResource(isCompact)
|
||||
} else {
|
||||
TwoButtons.getImageResource(isCompact)
|
||||
}
|
||||
return ThreeButtons.getImageResource(isCompact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.PinHashing
|
||||
import org.thoughtcrime.securesms.registration.fragments.BaseRegistrationLockFragment
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil
|
||||
|
||||
class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layout.fragment_change_number_registration_lock) {
|
||||
|
||||
@@ -42,7 +42,7 @@ class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layo
|
||||
}
|
||||
|
||||
override fun handleSuccessfulPinEntry(pin: String) {
|
||||
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashing.verifyLocalPinHash(it, pin) } ?: false
|
||||
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashUtil.verifyLocalPinHash(it, pin) } ?: false
|
||||
|
||||
pinButton.cancelSpinning()
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface.OnClickListener
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
@@ -46,6 +50,7 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
|
||||
if (!requestingCaptcha || viewModel.hasCaptchaToken()) {
|
||||
requestCode()
|
||||
} else {
|
||||
Log.d(TAG, "Captcha required.")
|
||||
Toast.makeText(requireContext(), R.string.ChangeNumberVerifyFragment__captcha_required, Toast.LENGTH_SHORT).show()
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
@@ -60,6 +65,7 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
|
||||
.andThen(viewModel.changeNumberWithRecoveryPassword())
|
||||
.flatMap { changed ->
|
||||
if (changed) {
|
||||
Log.d(TAG, "Successfully changed number using recovery password.")
|
||||
Single.just(RequestCodeResult.RecoveryPasswordWorked)
|
||||
} else {
|
||||
viewModel.requestVerificationCode(mode, mccMncProducer.mcc, mccMncProducer.mnc)
|
||||
@@ -83,16 +89,18 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
|
||||
requestingCaptcha = true
|
||||
} else if (processor.rateLimit()) {
|
||||
Log.i(TAG, "Unable to request sms code due to rate limit")
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show()
|
||||
findNavController().navigateUp()
|
||||
showErrorDialog(requireContext(), R.string.RegistrationActivity_rate_limited_to_service) { _, _ -> findNavController().navigateUp() }
|
||||
} else {
|
||||
Log.w(TAG, "Unable to request sms code", processor.error)
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show()
|
||||
findNavController().navigateUp()
|
||||
showErrorDialog(requireContext(), R.string.RegistrationActivity_unable_to_request_verification_code) { _, _ -> findNavController().navigateUp() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showErrorDialog(context: Context, @StringRes message: Int, onPositiveButtonClickListener: OnClickListener?) {
|
||||
MaterialAlertDialogBuilder(context).setMessage(message).setPositiveButton(R.string.ok, onPositiveButtonClickListener).show()
|
||||
}
|
||||
|
||||
private sealed interface RequestCodeResult {
|
||||
object RecoveryPasswordWorked : RequestCodeResult
|
||||
class RequestedVerificationCode(val processor: RegistrationSessionProcessor) : RequestCodeResult
|
||||
|
||||
@@ -44,6 +44,13 @@ class HelpSettingsFragment : DSLSettingsFragment(R.string.preferences__help) {
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.HelpSettingsFragment__licenses),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_helpSettingsFragment_to_licenseFragment)
|
||||
}
|
||||
)
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.HelpSettingsFragment__terms_amp_privacy_policy),
|
||||
linkId = R.string.terms_and_privacy_policy_url
|
||||
@@ -54,7 +61,7 @@ class HelpSettingsFragment : DSLSettingsFragment(R.string.preferences__help) {
|
||||
StringBuilder().apply {
|
||||
append(getString(R.string.HelpFragment__copyright_signal_messenger))
|
||||
append("\n")
|
||||
append(getString(R.string.HelpFragment__licenced_under_the_gplv3))
|
||||
append(getString(R.string.HelpFragment__licenced_under_the_agplv3))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.help
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.rxjava3.subscribeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
class LicenseFragment : ComposeFragment() {
|
||||
private val TAG = Log.tag(LicenseFragment::class.java)
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val textState: State<String> = Single.fromCallable {
|
||||
requireContext().resources.openRawResource(R.raw.third_party_licenses).bufferedReader().use { it.readText() }
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeAsState(initial = "")
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.HelpSettingsFragment__licenses),
|
||||
onNavigationClick = findNavController()::popBackStack,
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) {
|
||||
LicenseScreen(licenseText = textState.value, modifier = Modifier.padding(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LicenseScreen(licenseText: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = licenseText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LicenseFragmentPreview() {
|
||||
LicenseScreen("Lorem ipsum")
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Shows a QRCode that represents the provided data. Includes a Signal logo in the middle.
|
||||
*/
|
||||
@Composable
|
||||
fun QrCode(
|
||||
data: QrCodeData,
|
||||
modifier: Modifier = Modifier,
|
||||
foregroundColor: Color = Color.Black,
|
||||
backgroundColor: Color = Color.White,
|
||||
deadzonePercent: Float = 0.4f
|
||||
) {
|
||||
val logo = ImageBitmap.imageResource(R.drawable.qrcode_logo)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.drawBehind {
|
||||
drawQr(
|
||||
data = data,
|
||||
foregroundColor = foregroundColor,
|
||||
backgroundColor = backgroundColor,
|
||||
deadzonePercent = deadzonePercent,
|
||||
logo = logo
|
||||
)
|
||||
}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawQr(
|
||||
data: QrCodeData,
|
||||
foregroundColor: Color,
|
||||
backgroundColor: Color,
|
||||
deadzonePercent: Float,
|
||||
logo: ImageBitmap
|
||||
) {
|
||||
// We want an even number of dots on either side of the deadzone
|
||||
val candidateDeadzoneWidth: Int = (data.width * deadzonePercent).toInt()
|
||||
val deadzoneWidth: Int = if ((data.width - candidateDeadzoneWidth) % 2 == 0) {
|
||||
candidateDeadzoneWidth
|
||||
} else {
|
||||
candidateDeadzoneWidth + 1
|
||||
}
|
||||
|
||||
val candidateDeadzoneHeight: Int = (data.height * deadzonePercent).toInt()
|
||||
val deadzoneHeight: Int = if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
|
||||
candidateDeadzoneHeight
|
||||
} else {
|
||||
candidateDeadzoneHeight + 1
|
||||
}
|
||||
|
||||
val deadzoneStartX: Int = (data.width - deadzoneWidth) / 2
|
||||
val deadzoneEndX: Int = deadzoneStartX + deadzoneWidth
|
||||
val deadzoneStartY: Int = (data.height - deadzoneHeight) / 2
|
||||
val deadzoneEndY: Int = deadzoneStartY + deadzoneHeight
|
||||
|
||||
val cellWidthPx: Float = size.width / data.width
|
||||
val cellRadiusPx = cellWidthPx / 2
|
||||
|
||||
for (x in 0 until data.width) {
|
||||
for (y in 0 until data.height) {
|
||||
if (x < deadzoneStartX || x >= deadzoneEndX || y < deadzoneStartY || y >= deadzoneEndY) {
|
||||
drawCircle(
|
||||
color = if (data.get(x, y)) foregroundColor else backgroundColor,
|
||||
radius = cellRadiusPx,
|
||||
center = Offset(x * cellWidthPx + cellRadiusPx, y * cellWidthPx + cellRadiusPx)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logo border
|
||||
val deadzonePaddingPercent = 0.03f
|
||||
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
|
||||
drawCircle(
|
||||
color = foregroundColor,
|
||||
radius = logoBorderRadiusPx,
|
||||
style = Stroke(width = 4.dp.toPx()),
|
||||
center = this.center
|
||||
)
|
||||
|
||||
// Logo
|
||||
val logoWidthPx = ((deadzonePercent / 2) * size.width).toInt()
|
||||
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
|
||||
drawImage(
|
||||
image = logo,
|
||||
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
|
||||
dstSize = IntSize(logoWidthPx, logoWidthPx),
|
||||
colorFilter = ColorFilter.tint(foregroundColor)
|
||||
)
|
||||
|
||||
for (eye in data.eyes()) {
|
||||
val strokeWidth = cellWidthPx
|
||||
|
||||
// Clear the already-drawn dots
|
||||
drawRect(
|
||||
color = backgroundColor,
|
||||
topLeft = Offset(
|
||||
x = eye.position.first * cellWidthPx,
|
||||
y = eye.position.second * cellWidthPx
|
||||
),
|
||||
size = Size(eye.size * cellWidthPx + cellRadiusPx, eye.size * cellWidthPx)
|
||||
)
|
||||
|
||||
// Outer square
|
||||
drawRoundRect(
|
||||
color = foregroundColor,
|
||||
topLeft = Offset(
|
||||
x = eye.position.first * cellWidthPx + strokeWidth / 2,
|
||||
y = eye.position.second * cellWidthPx + strokeWidth / 2
|
||||
),
|
||||
size = Size((eye.size - 1) * cellWidthPx, (eye.size - 1) * cellWidthPx),
|
||||
cornerRadius = CornerRadius(cellRadiusPx * 2, cellRadiusPx * 2),
|
||||
style = Stroke(width = strokeWidth)
|
||||
)
|
||||
|
||||
// Inner square
|
||||
drawRoundRect(
|
||||
color = foregroundColor,
|
||||
topLeft = Offset(
|
||||
x = (eye.position.first + 2) * cellWidthPx,
|
||||
y = (eye.position.second + 2) * cellWidthPx
|
||||
),
|
||||
size = Size((eye.size - 4) * cellWidthPx, (eye.size - 4) * cellWidthPx),
|
||||
cornerRadius = CornerRadius(cellRadiusPx, cellRadiusPx)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
Surface {
|
||||
QrCode(
|
||||
data = QrCodeData.forData("https://signal.org", 64),
|
||||
modifier = Modifier.size(200.dp),
|
||||
deadzonePercent = 0.3f
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
|
||||
/**
|
||||
* Renders a QR code and username as a badge.
|
||||
*/
|
||||
@Composable
|
||||
fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier) {
|
||||
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor)
|
||||
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor)
|
||||
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f)
|
||||
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 59.dp, vertical = 24.dp),
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
shadowElevation = elevation.dp
|
||||
) {
|
||||
Column {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = 32.dp,
|
||||
start = 40.dp,
|
||||
end = 40.dp,
|
||||
bottom = 16.dp
|
||||
)
|
||||
.aspectRatio(1f)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White
|
||||
) {
|
||||
if (data != null) {
|
||||
QrCode(
|
||||
data = data,
|
||||
modifier = Modifier.padding(20.dp),
|
||||
foregroundColor = foregroundColor,
|
||||
backgroundColor = Color.White
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = colorScheme.borderColor,
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = username,
|
||||
color = textColor,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = 40.dp,
|
||||
end = 40.dp,
|
||||
bottom = 32.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreviewWithCode() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
QrCodeBadge(
|
||||
data = QrCodeData.forData("https://signal.org", 64),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreviewWithoutCode() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
QrCodeBadge(
|
||||
data = null,
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import java.util.BitSet
|
||||
|
||||
/**
|
||||
* Efficient representation of raw QR code data. Stored as an X/Y grid of points, where (0, 0) is the top left corner.
|
||||
* X increases as you move right, and Y increases as you go down.
|
||||
*/
|
||||
class QrCodeData(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
private val bits: BitSet
|
||||
) {
|
||||
|
||||
fun get(x: Int, y: Int): Boolean {
|
||||
return bits.get(y * width + x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position of the "eyes" of the QR code -- the big squares in the three corners.
|
||||
*/
|
||||
fun eyes(): List<Eye> {
|
||||
val eyes: MutableList<Eye> = mutableListOf()
|
||||
|
||||
val size: Int = getPossibleEyeSize()
|
||||
|
||||
// Top left
|
||||
if (
|
||||
horizontalLineExists(0, 0, size) &&
|
||||
horizontalLineExists(0, size - 1, size) &&
|
||||
verticalLineExists(0, 0, size) &&
|
||||
verticalLineExists(size - 1, 0, size)
|
||||
) {
|
||||
eyes += Eye(
|
||||
position = 0 to 0,
|
||||
size = size
|
||||
)
|
||||
}
|
||||
|
||||
// Bottom left
|
||||
if (
|
||||
horizontalLineExists(0, height - size, size) &&
|
||||
horizontalLineExists(0, size - 1, size) &&
|
||||
verticalLineExists(0, height - size, size) &&
|
||||
verticalLineExists(size - 1, height - size, size)
|
||||
) {
|
||||
eyes += Eye(
|
||||
position = 0 to height - size,
|
||||
size = size
|
||||
)
|
||||
}
|
||||
|
||||
// Top right
|
||||
if (
|
||||
horizontalLineExists(width - size, 0, size) &&
|
||||
horizontalLineExists(width - size, size - 1, size) &&
|
||||
verticalLineExists(width - size, 0, size) &&
|
||||
verticalLineExists(width - 1, 0, size)
|
||||
) {
|
||||
eyes += Eye(
|
||||
position = width - size to 0,
|
||||
size = size
|
||||
)
|
||||
}
|
||||
|
||||
return eyes
|
||||
}
|
||||
|
||||
private fun getPossibleEyeSize(): Int {
|
||||
var x = 0
|
||||
|
||||
while (get(x, 0)) {
|
||||
x++
|
||||
}
|
||||
|
||||
return x
|
||||
}
|
||||
|
||||
private fun horizontalLineExists(x: Int, y: Int, length: Int): Boolean {
|
||||
for (p in x until x + length) {
|
||||
if (!get(p, y)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun verticalLineExists(x: Int, y: Int, length: Int): Boolean {
|
||||
for (p in y until y + length) {
|
||||
if (!get(x, p)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
data class Eye(
|
||||
val position: Pair<Int, Int>,
|
||||
val size: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Converts the provided string data into a QR representation.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun forData(data: String, size: Int): QrCodeData {
|
||||
val qrCodeWriter = QRCodeWriter()
|
||||
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H.toString())
|
||||
|
||||
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val dimens = padded.enclosingRectangle
|
||||
val xStart = dimens[0]
|
||||
val yStart = dimens[1]
|
||||
val width = dimens[2]
|
||||
val height = dimens[3]
|
||||
val bitSet = BitSet(width * height)
|
||||
|
||||
for (x in xStart until xStart + width) {
|
||||
for (y in yStart until yStart + height) {
|
||||
if (padded.get(x, y)) {
|
||||
val destX = x - xStart
|
||||
val destY = y - yStart
|
||||
bitSet.set(destY * width + destX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return QrCodeData(width, height, bitSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* A set of color schemes for sharing QR codes.
|
||||
*/
|
||||
enum class UsernameQrCodeColorScheme(
|
||||
val borderColor: Color,
|
||||
val foregroundColor: Color,
|
||||
private val key: String
|
||||
) {
|
||||
Blue(
|
||||
borderColor = Color(0xFF506ECD),
|
||||
foregroundColor = Color(0xFF2449C0),
|
||||
key = "blue"
|
||||
),
|
||||
White(
|
||||
borderColor = Color(0xFFFFFFFF),
|
||||
foregroundColor = Color(0xFF464852),
|
||||
key = "white"
|
||||
),
|
||||
Grey(
|
||||
borderColor = Color(0xFF6A6C74),
|
||||
foregroundColor = Color(0xFF464852),
|
||||
key = "grey"
|
||||
),
|
||||
Tan(
|
||||
borderColor = Color(0xFFBBB29A),
|
||||
foregroundColor = Color(0xFF73694F),
|
||||
key = "tan"
|
||||
),
|
||||
Green(
|
||||
borderColor = Color(0xFF97AA89),
|
||||
foregroundColor = Color(0xFF55733F),
|
||||
key = "green"
|
||||
),
|
||||
Orange(
|
||||
borderColor = Color(0xFFDE7134),
|
||||
foregroundColor = Color(0xFFDA6C2E),
|
||||
key = "orange"
|
||||
),
|
||||
Pink(
|
||||
borderColor = Color(0xFFEA7B9D),
|
||||
foregroundColor = Color(0xFFBB617B),
|
||||
key = "pink"
|
||||
),
|
||||
Purple(
|
||||
borderColor = Color(0xFF9E7BE9),
|
||||
foregroundColor = Color(0xFF7651C5),
|
||||
key = "purple"
|
||||
);
|
||||
|
||||
fun serialize(): String {
|
||||
return key
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Returns the [UsernameQrCodeColorScheme] based on the serialized string. If no match is found, the default of [Blue] is returned.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun deserialize(serialized: String?): UsernameQrCodeColorScheme {
|
||||
return values().firstOrNull { it.key == serialized } ?: Blue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
/**
|
||||
* Gives the user the ability to change the color of their shareable username QR code with a live preview.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class UsernameLinkQrColorPickerFragment : ComposeFragment() {
|
||||
|
||||
val viewModel: UsernameLinkQrColorPickerViewModel by viewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state: UsernameLinkQrColorPickerState by viewModel.state
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopAppBarContent(onBackClicked = { navController.popBackStack() }) }
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
QrCodeBadge(
|
||||
data = state.qrCodeData,
|
||||
colorScheme = state.selectedColorScheme,
|
||||
username = state.username
|
||||
)
|
||||
|
||||
ColorPicker(
|
||||
colors = state.colorSchemes,
|
||||
selected = state.selectedColorScheme,
|
||||
onSelectionChanged = { color -> viewModel.onColorSelected(color) }
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f, false)
|
||||
.fillMaxWidth()
|
||||
.padding(end = 24.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Buttons.MediumTonal(onClick = { navController.popBackStack() }) {
|
||||
Text(stringResource(R.string.UsernameLinkSettings_done_button_label))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopAppBarContent(onBackClicked: () -> Unit) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(stringResource(R.string.UsernameLinkSettings_color_picker_app_bar_title))
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Image(painter = painterResource(R.drawable.symbol_arrow_left_24), contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorPicker(colors: ImmutableList<UsernameQrCodeColorScheme>, selected: UsernameQrCodeColorScheme, onSelectionChanged: (UsernameQrCodeColorScheme) -> Unit) {
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier.padding(horizontal = 30.dp),
|
||||
columns = GridCells.Adaptive(minSize = 88.dp)
|
||||
) {
|
||||
colors.forEach { color ->
|
||||
item(key = color.serialize()) {
|
||||
ColorPickerItem(
|
||||
color = color,
|
||||
selected = color == selected,
|
||||
onClick = {
|
||||
onSelectionChanged(color)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorPickerItem(color: UsernameQrCodeColorScheme, selected: Boolean, onClick: () -> Unit) {
|
||||
val outerBorderColor by animateColorAsState(targetValue = if (selected) MaterialTheme.colorScheme.onBackground else Color.Transparent)
|
||||
val colorCircleSize by animateFloatAsState(targetValue = if (selected) 44f else 56f)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp)
|
||||
.border(width = 2.dp, color = outerBorderColor, shape = CircleShape)
|
||||
.size(56.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.border(width = 2.dp, color = Color.Black.copy(alpha = 0.12f), shape = CircleShape)
|
||||
.size(colorCircleSize.dp),
|
||||
shape = CircleShape,
|
||||
color = color.borderColor,
|
||||
content = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ColorPickerItemPreview() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ColorPickerItem(color = UsernameQrCodeColorScheme.Blue, selected = false, onClick = {})
|
||||
ColorPickerItem(color = UsernameQrCodeColorScheme.Blue, selected = true, onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ColorPickerPreview() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
ColorPicker(
|
||||
colors = UsernameQrCodeColorScheme.values().toList().toImmutableList(),
|
||||
selected = UsernameQrCodeColorScheme.Blue,
|
||||
onSelectionChanged = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user