mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 15:37:29 +00:00
Compare commits
388 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61d7a9f77 | ||
|
|
ee0ab8f035 | ||
|
|
6e85c74e3f | ||
|
|
3b1aa5b176 | ||
|
|
715ad0d459 | ||
|
|
05f7dce503 | ||
|
|
ecfbeb69c5 | ||
|
|
c7fb343b93 | ||
|
|
b0d0814b88 | ||
|
|
9f3765d368 | ||
|
|
fe82c4e487 | ||
|
|
e9bbb1b9ae | ||
|
|
fd3e88707c | ||
|
|
8e8def8b03 | ||
|
|
5b1069018f | ||
|
|
abc71f4fb4 | ||
|
|
2f5e95c0e3 | ||
|
|
d1fd70a807 | ||
|
|
1b924c606a | ||
|
|
9b175fa0dd | ||
|
|
7e7bbad788 | ||
|
|
d8c82add78 | ||
|
|
6e1657b1bd | ||
|
|
1f7b1d91c4 | ||
|
|
e7833df539 | ||
|
|
fae80a242d | ||
|
|
77ff25ec49 | ||
|
|
bb446ac1d5 | ||
|
|
b6a4d01d42 | ||
|
|
bd4dd25460 | ||
|
|
f86c1fe508 | ||
|
|
38f6efbcae | ||
|
|
30a542234b | ||
|
|
8c9bf678fa | ||
|
|
4b465b74e8 | ||
|
|
58a22f0eea | ||
|
|
ddad9acef1 | ||
|
|
1dbb6013cb | ||
|
|
9cc1ae4a29 | ||
|
|
4eb24c3303 | ||
|
|
ec1935572e | ||
|
|
e419d70d51 | ||
|
|
2af5526879 | ||
|
|
6a5aa089ae | ||
|
|
6b5f4ca8c2 | ||
|
|
53e110560a | ||
|
|
82e9c620e8 | ||
|
|
076facbdc2 | ||
|
|
a805f9b6b4 | ||
|
|
969e763997 | ||
|
|
9347227ff5 | ||
|
|
c9ba0432a0 | ||
|
|
e3b7fe7509 | ||
|
|
5332669321 | ||
|
|
a086305c38 | ||
|
|
a712622891 | ||
|
|
1514f91687 | ||
|
|
0dfa6aab09 | ||
|
|
4b6dbac758 | ||
|
|
b816f901a5 | ||
|
|
76d1490810 | ||
|
|
f2ab0b6423 | ||
|
|
e09d162c1e | ||
|
|
c84de8fa60 | ||
|
|
8e020c05f6 | ||
|
|
8c9eb880cf | ||
|
|
d7ddd85a90 | ||
|
|
7d994b2ae1 | ||
|
|
664d6475d9 | ||
|
|
a940487611 | ||
|
|
9f995d61f4 | ||
|
|
a6690e1bde | ||
|
|
d507df2e7e | ||
|
|
fa26eb2017 | ||
|
|
0b53ba8950 | ||
|
|
7447e2497b | ||
|
|
7ac83625d3 | ||
|
|
50dfe7bc25 | ||
|
|
8e32592218 | ||
|
|
a3d72fc06c | ||
|
|
f5a6d61362 | ||
|
|
bca2205945 | ||
|
|
1241f4c0e9 | ||
|
|
f6253ad0bb | ||
|
|
083301185c | ||
|
|
0273d0f285 | ||
|
|
3dc1ce3353 | ||
|
|
f8e077b824 | ||
|
|
aec2ca1d87 | ||
|
|
6e7a18ea11 | ||
|
|
fe54ec9d6c | ||
|
|
1819af3000 | ||
|
|
3c177c4883 | ||
|
|
2c871a36d0 | ||
|
|
6bde55f73b | ||
|
|
50b4e137b4 | ||
|
|
4f6d39859c | ||
|
|
45a6894da1 | ||
|
|
f71accea06 | ||
|
|
32888fa00b | ||
|
|
eba3c55ec8 | ||
|
|
21b82e291b | ||
|
|
8d9d84c4cc | ||
|
|
4c25264fbf | ||
|
|
7410d664dd | ||
|
|
c878ba3cdf | ||
|
|
97798a146f | ||
|
|
7c134a6c9d | ||
|
|
08008629b3 | ||
|
|
a57adcb2b0 | ||
|
|
7790cac0ee | ||
|
|
349ad06c45 | ||
|
|
3a75d30732 | ||
|
|
b48d4f3ec2 | ||
|
|
c92f36f9a8 | ||
|
|
faa36d417c | ||
|
|
a2b6e003b6 | ||
|
|
406af58394 | ||
|
|
bd72fc8464 | ||
|
|
05fb1a52d2 | ||
|
|
b21abb8e7e | ||
|
|
b41e602539 | ||
|
|
3f233ed39f | ||
|
|
ade6f60e76 | ||
|
|
62d85e6878 | ||
|
|
4d985255a8 | ||
|
|
fd3ef0f557 | ||
|
|
7f30300cd4 | ||
|
|
0459d118a3 | ||
|
|
c92f3b5dfd | ||
|
|
ba4d1c9844 | ||
|
|
8c3a0c1f9f | ||
|
|
1dc2a35d83 | ||
|
|
0a67731830 | ||
|
|
28d86886bd | ||
|
|
b1fcea673a | ||
|
|
eb5418787a | ||
|
|
adbda02aa4 | ||
|
|
307f47fa33 | ||
|
|
c1fb4f9421 | ||
|
|
6179c087fb | ||
|
|
ae2ba5d185 | ||
|
|
91128be8f6 | ||
|
|
8748056130 | ||
|
|
3c4e3cf048 | ||
|
|
eb48ab1784 | ||
|
|
665d9e31f6 | ||
|
|
db7272730e | ||
|
|
5787a5f68a | ||
|
|
1a21cafe6c | ||
|
|
7465818f44 | ||
|
|
62cb29fdb7 | ||
|
|
a85b08d9da | ||
|
|
b18c3ec1a9 | ||
|
|
29489a664e | ||
|
|
dbb1e50d00 | ||
|
|
2068fa8041 | ||
|
|
194975d068 | ||
|
|
b7a067e954 | ||
|
|
1e050915ef | ||
|
|
6a5c234408 | ||
|
|
7a1122b3f7 | ||
|
|
962d943a22 | ||
|
|
dbcc5d696d | ||
|
|
9232eb7c16 | ||
|
|
fc9b8f43dd | ||
|
|
5e8d74bc11 | ||
|
|
642d1984c4 | ||
|
|
0ab2100fa5 | ||
|
|
6618d696e4 | ||
|
|
c24dfdce34 | ||
|
|
214e994e90 | ||
|
|
b904de5b50 | ||
|
|
ad7c81ef4e | ||
|
|
3e8b5cdb61 | ||
|
|
6aea849a42 | ||
|
|
cd0bf470a9 | ||
|
|
c615b14c51 | ||
|
|
28bf6d300e | ||
|
|
a1095f966c | ||
|
|
58a8902d4e | ||
|
|
e582976293 | ||
|
|
143110047d | ||
|
|
c1324c7496 | ||
|
|
53eee2bd16 | ||
|
|
86b1d104d9 | ||
|
|
d1d2376210 | ||
|
|
7bede7e98a | ||
|
|
fec4a7692d | ||
|
|
b58cede072 | ||
|
|
199fb517b1 | ||
|
|
921addf4c8 | ||
|
|
61aa991d79 | ||
|
|
c1c95e1ae2 | ||
|
|
f95a29b0d4 | ||
|
|
f7bb9c85af | ||
|
|
ae30e4070c | ||
|
|
9a67c60b4e | ||
|
|
e86b26bd11 | ||
|
|
e7c259b1e9 | ||
|
|
c65761a034 | ||
|
|
0b37b0ee16 | ||
|
|
d76e58ce09 | ||
|
|
2b366f8c9c | ||
|
|
d43f7d6ad9 | ||
|
|
5b7932281e | ||
|
|
0599f76ed5 | ||
|
|
31e0f3edfb | ||
|
|
17b568e6d1 | ||
|
|
7c11962cb3 | ||
|
|
a7c4199192 | ||
|
|
8cb3909093 | ||
|
|
7480ea66ec | ||
|
|
8e94ced7b6 | ||
|
|
ffd86a96da | ||
|
|
d4cabce876 | ||
|
|
a5790edb2b | ||
|
|
d247e2eabe | ||
|
|
f4d6de466b | ||
|
|
0838c0be27 | ||
|
|
7448183ff4 | ||
|
|
8e2a265cf3 | ||
|
|
8802cebb64 | ||
|
|
0c6fe8bea3 | ||
|
|
49334ffd42 | ||
|
|
4702ab1aeb | ||
|
|
fe8fcb1394 | ||
|
|
dc1e56de4e | ||
|
|
561cca5208 | ||
|
|
a291732c1a | ||
|
|
28abc1e4ff | ||
|
|
655e43a079 | ||
|
|
94b9a458e7 | ||
|
|
bb75730315 | ||
|
|
824a8ac5f2 | ||
|
|
3baf10f0e9 | ||
|
|
cfab195e90 | ||
|
|
503d7c77a0 | ||
|
|
f99ff32947 | ||
|
|
182c758d35 | ||
|
|
f6b2d3faf8 | ||
|
|
61c7959ffc | ||
|
|
67ccd14af2 | ||
|
|
3c41b7322f | ||
|
|
d2118d0b53 | ||
|
|
89af85c893 | ||
|
|
0762a93787 | ||
|
|
570b4d7150 | ||
|
|
fc51c4940c | ||
|
|
b9ffbb8e92 | ||
|
|
de2c7d38bf | ||
|
|
c9597ef8dc | ||
|
|
a9bbee3880 | ||
|
|
2bac1a7707 | ||
|
|
80e1b2c843 | ||
|
|
7c2da69676 | ||
|
|
f25e8d602b | ||
|
|
b9c6c6b0f4 | ||
|
|
164f39e376 | ||
|
|
49190125ef | ||
|
|
555e65d3ee | ||
|
|
89b1243885 | ||
|
|
3fca46de92 | ||
|
|
aa0d7c218f | ||
|
|
2b5b664a8f | ||
|
|
784c373a0e | ||
|
|
37ae740138 | ||
|
|
fe9b8a9f47 | ||
|
|
3585667fb9 | ||
|
|
b4ed088529 | ||
|
|
bdcf390e6e | ||
|
|
c7551881b8 | ||
|
|
c131754874 | ||
|
|
c6c4988583 | ||
|
|
d43f044eb4 | ||
|
|
9c71994804 | ||
|
|
654d98b0fe | ||
|
|
c06056d847 | ||
|
|
615fbf87c9 | ||
|
|
aca3d150bf | ||
|
|
6eae2d39a8 | ||
|
|
c78e283084 | ||
|
|
2d5492ffac | ||
|
|
2830132b24 | ||
|
|
e374f3afe6 | ||
|
|
58a2e50904 | ||
|
|
be29dce7b7 | ||
|
|
e58b617689 | ||
|
|
9d3a9fc675 | ||
|
|
8bddf5206c | ||
|
|
0ac234e7bf | ||
|
|
290f84e5b1 | ||
|
|
149c138666 | ||
|
|
065a39992a | ||
|
|
4a52532256 | ||
|
|
93f541ceca | ||
|
|
e97a1b2cf6 | ||
|
|
f6b46f921c | ||
|
|
5fef0494b1 | ||
|
|
6e1621fef1 | ||
|
|
e5f1793eb3 | ||
|
|
a994712609 | ||
|
|
3b8eac0b8d | ||
|
|
52978b1b42 | ||
|
|
922d0d7203 | ||
|
|
429fdf0d76 | ||
|
|
1f718009bd | ||
|
|
c1c9ca7c4c | ||
|
|
75421b1af8 | ||
|
|
d40bb2d9ee | ||
|
|
7c8549bf5e | ||
|
|
fb8f481a87 | ||
|
|
8caa690086 | ||
|
|
d7011e3353 | ||
|
|
9af966b030 | ||
|
|
a46accfcc0 | ||
|
|
c0c4092cd9 | ||
|
|
9398716848 | ||
|
|
25234496bf | ||
|
|
1a56924a56 | ||
|
|
d168d35362 | ||
|
|
3cc2cd0b17 | ||
|
|
138b7ea796 | ||
|
|
1f1a4eb351 | ||
|
|
b9081dc942 | ||
|
|
e76808a000 | ||
|
|
e31fd8d578 | ||
|
|
7d8f780d60 | ||
|
|
0478757af4 | ||
|
|
712b0c147a | ||
|
|
fc6db45e59 | ||
|
|
5229e24397 | ||
|
|
927b6096c6 | ||
|
|
16f1128990 | ||
|
|
4fd1d05503 | ||
|
|
dfac05a118 | ||
|
|
cd869bcb89 | ||
|
|
427119cef2 | ||
|
|
dada7a4f06 | ||
|
|
44a84210d8 | ||
|
|
5ac8d3b0bd | ||
|
|
7ccba5b1c8 | ||
|
|
c2ffd8adbb | ||
|
|
7e4396ae3f | ||
|
|
d0827eb48e | ||
|
|
90397165c3 | ||
|
|
e3e47504a6 | ||
|
|
42269efa57 | ||
|
|
38adb0373d | ||
|
|
8bde389398 | ||
|
|
d29b0609a3 | ||
|
|
740977164b | ||
|
|
2dd8f24e14 | ||
|
|
4e409fc9ed | ||
|
|
136826be69 | ||
|
|
0ba7ff911b | ||
|
|
bfbdbdcbc0 | ||
|
|
f2533ac4b7 | ||
|
|
15a5f5966d | ||
|
|
3c748b2df6 | ||
|
|
c1b54b3532 | ||
|
|
ab56856f41 | ||
|
|
ce31e642dd | ||
|
|
aa67c82634 | ||
|
|
c9c4187d2e | ||
|
|
60b4862b1b | ||
|
|
b2c3a34d68 | ||
|
|
90925f4d8c | ||
|
|
833f90ce53 | ||
|
|
26c9b5166e | ||
|
|
a27d60f830 | ||
|
|
a75f634c0a | ||
|
|
963c018e0c | ||
|
|
6cc0eed5fe | ||
|
|
cdcc7b6fa5 | ||
|
|
24482b5a65 | ||
|
|
b100262c6a | ||
|
|
ed23c3fe7c | ||
|
|
0093e1d3eb | ||
|
|
7419da7247 | ||
|
|
0b85852621 | ||
|
|
556518973d | ||
|
|
b9514d0b94 | ||
|
|
a9dab90a1e | ||
|
|
39709c8d64 | ||
|
|
c2a6963a6d | ||
|
|
bfdebbfa5d | ||
|
|
167a691018 |
4
.github/workflows/android.yml
vendored
4
.github/workflows/android.yml
vendored
@@ -24,8 +24,8 @@ jobs:
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Remove Android S
|
||||
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-S"
|
||||
- name: Remove Android 31 (S)
|
||||
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-31"
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew qa
|
||||
|
||||
32
.idea/codeStyles/Project.xml
generated
32
.idea/codeStyles/Project.xml
generated
@@ -8,6 +8,38 @@
|
||||
<option name="DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION" value="true" />
|
||||
<option name="ALIGN_MULTILINE_ANNOTATION_PARAMETERS" value="true" />
|
||||
<option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" />
|
||||
<option name="IMPORT_LAYOUT_TABLE">
|
||||
<value>
|
||||
<package name="android" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="androidx" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="com" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="junit" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="net" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="org" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="java" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="javax" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="android" withSubpackages="true" static="true" />
|
||||
<package name="androidx" withSubpackages="true" static="true" />
|
||||
<package name="com" withSubpackages="true" static="true" />
|
||||
<package name="junit" withSubpackages="true" static="true" />
|
||||
<package name="net" withSubpackages="true" static="true" />
|
||||
<package name="org" withSubpackages="true" static="true" />
|
||||
<package name="java" withSubpackages="true" static="true" />
|
||||
<package name="javax" withSubpackages="true" static="true" />
|
||||
<package name="" withSubpackages="true" static="true" />
|
||||
<emptyLine />
|
||||
</value>
|
||||
</option>
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
|
||||
@@ -4,7 +4,7 @@ Signal is a messaging app for simple private communication with friends.
|
||||
|
||||
Signal uses your phone's data connection (WiFi/3G/4G) to communicate securely, optionally supports plain SMS/MMS to function as a unified messenger, and can also encrypt the stored messages on your phone.
|
||||
|
||||
Currently available on the Play store.
|
||||
Currently available on the Play store and [signal.org](https://signal.org/android/apk/).
|
||||
|
||||
<a href='https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png' height='80px'/></a>
|
||||
|
||||
@@ -59,8 +59,8 @@ The form and manner of this distribution makes it eligible for export under the
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2013-2020 Signal
|
||||
Copyright 2013-2021 Signal
|
||||
|
||||
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
Google Play and the Google Play logo are trademarks of Google Inc.
|
||||
Google Play and the Google Play logo are trademarks of Google LLC.
|
||||
|
||||
157
app/build.gradle
157
app/build.gradle
@@ -1,7 +1,3 @@
|
||||
import org.signal.signing.ApkSignerUtil
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
@@ -27,6 +23,12 @@ repositories {
|
||||
includeGroupByRegex "com\\.github\\.dmytrodanylyk\\.circular-progress-button\\.*"
|
||||
}
|
||||
}
|
||||
maven {
|
||||
url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/"
|
||||
content {
|
||||
includeGroupByRegex "org\\.signal.*"
|
||||
}
|
||||
}
|
||||
maven { // textdrawable
|
||||
url 'https://dl.bintray.com/amulyakhare/maven'
|
||||
content {
|
||||
@@ -35,11 +37,23 @@ repositories {
|
||||
}
|
||||
google()
|
||||
mavenCentral()
|
||||
jcenter()
|
||||
mavenLocal()
|
||||
maven {
|
||||
url "https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/"
|
||||
}
|
||||
jcenter {
|
||||
content {
|
||||
includeVersion "com.google.android.exoplayer", "exoplayer-core", "2.9.1"
|
||||
includeVersion "com.google.android.exoplayer", "exoplayer-ui", "2.9.1"
|
||||
includeVersion "com.google.android.exoplayer", "extension-mediasession", "2.9.1"
|
||||
includeVersion "mobi.upod", "time-duration-picker", "1.1.3"
|
||||
includeVersion "cn.carbswang.android", "NumberPickerView", "1.0.9"
|
||||
includeVersion "com.takisoft.fix", "colorpicker", "0.9.1"
|
||||
includeVersion "com.codewaves.stickyheadergrid", "stickyheadergrid", "0.9.4"
|
||||
includeVersion "com.amulyakhare", "com.amulyakhare.textdrawable", "1.0.1"
|
||||
includeVersion "com.google.android", "flexbox", "0.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
@@ -57,8 +71,8 @@ protobuf {
|
||||
}
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 879
|
||||
def canonicalVersionName = "5.17.1"
|
||||
def canonicalVersionCode = 916
|
||||
def canonicalVersionName = "5.23.4"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -69,6 +83,31 @@ def abiPostFix = ['universal' : 0,
|
||||
|
||||
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
|
||||
|
||||
def selectableVariants = [
|
||||
'internalProdFlipper',
|
||||
'internalProdPerf',
|
||||
'internalProdRelease',
|
||||
'internalStagingFlipper',
|
||||
'internalStagingPerf',
|
||||
'internalStagingRelease',
|
||||
'nightlyProdFlipper',
|
||||
'nightlyProdPerf',
|
||||
'nightlyProdRelease',
|
||||
'nightlyStagingPerf',
|
||||
'playProdDebug',
|
||||
'playProdFlipper',
|
||||
'playProdPerf',
|
||||
'playProdRelease',
|
||||
'playStagingDebug',
|
||||
'playStagingFlipper',
|
||||
'playStagingPerf',
|
||||
'playStagingRelease',
|
||||
'studyProdMock',
|
||||
'studyProdPerf',
|
||||
'websiteProdFlipper',
|
||||
'websiteProdRelease',
|
||||
]
|
||||
|
||||
android {
|
||||
buildToolsVersion BUILD_TOOL_VERSION
|
||||
compileSdkVersion COMPILE_SDK
|
||||
@@ -133,7 +172,7 @@ android {
|
||||
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\""
|
||||
buildConfigField "int[]", "MOBILE_COIN_REGIONS", "new int[]{44}"
|
||||
buildConfigField "int[]", "MOBILE_COIN_REGIONS", "new int[]{44,49,33,41}"
|
||||
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
|
||||
|
||||
@@ -268,7 +307,7 @@ android {
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
|
||||
}
|
||||
|
||||
study {
|
||||
@@ -303,7 +342,7 @@ android {
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||
buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
|
||||
"\"51a56084c0b21c6b8f62b1bc792ec9bedac4c7c3964bb08ddcab868158c09982\", " +
|
||||
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
|
||||
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
@@ -339,16 +378,9 @@ android {
|
||||
def distribution = variant.getFlavors().get(0).name
|
||||
def environment = variant.getFlavors().get(1).name
|
||||
def buildType = variant.buildType.name
|
||||
def fullName = distribution + environment.capitalize() + buildType.capitalize()
|
||||
|
||||
if (distribution == 'study' && buildType != 'perf' && buildType != 'mock') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution != 'study' && buildType == 'mock') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution == 'internal' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution == 'nightly' && environment != 'prod') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution == 'nightly' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
|
||||
if (!selectableVariants.contains(fullName)) {
|
||||
variant.setIgnore(true)
|
||||
}
|
||||
}
|
||||
@@ -368,7 +400,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.5.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.2.5'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.5'
|
||||
lintChecks project(':lintchecks')
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
@@ -376,6 +408,7 @@ dependencies {
|
||||
implementation ('androidx.appcompat:appcompat:1.2.0') {
|
||||
force = true
|
||||
}
|
||||
implementation "androidx.window:window:1.0.0-alpha09"
|
||||
implementation 'androidx.recyclerview:recyclerview:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.3.0'
|
||||
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
|
||||
@@ -386,11 +419,12 @@ dependencies {
|
||||
implementation 'androidx.exifinterface:exifinterface:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'androidx.navigation:navigation-fragment:2.1.0'
|
||||
implementation 'androidx.navigation:navigation-ui:2.1.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:1.0.0-alpha05'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.3.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-reactivestreams-ktx:2.3.1'
|
||||
implementation "androidx.camera:camera-core:1.0.0-beta11"
|
||||
implementation "androidx.camera:camera-camera2:1.0.0-beta11"
|
||||
implementation "androidx.camera:camera-lifecycle:1.0.0-beta11"
|
||||
@@ -409,9 +443,9 @@ dependencies {
|
||||
implementation 'com.google.android.gms:play-services-maps:16.1.0'
|
||||
implementation 'com.google.android.gms:play-services-auth:16.0.1'
|
||||
|
||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
|
||||
implementation 'com.google.android.exoplayer:extension-mediasession:2.9.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0'
|
||||
implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0'
|
||||
|
||||
implementation 'org.conscrypt:conscrypt-android:2.0.0'
|
||||
implementation 'org.signal:aesgcmprovider:0.0.3'
|
||||
@@ -423,7 +457,7 @@ dependencies {
|
||||
implementation project(':device-transfer')
|
||||
|
||||
implementation 'org.signal:zkgroup-android:0.7.0'
|
||||
implementation 'org.whispersystems:signal-client-android:0.8.3'
|
||||
implementation "org.whispersystems:signal-client-android:${LIBSIGNAL_CLIENT_VERSION}"
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
|
||||
|
||||
implementation('com.mobilecoin:android-sdk:1.1.0') {
|
||||
@@ -432,7 +466,7 @@ dependencies {
|
||||
|
||||
implementation 'org.signal:argon2:13.1@aar'
|
||||
|
||||
implementation 'org.signal:ringrtc-android:2.10.6'
|
||||
implementation 'org.signal:ringrtc-android:2.11.1'
|
||||
|
||||
implementation "me.leolin:ShortcutBadger:1.1.22"
|
||||
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
|
||||
@@ -475,7 +509,7 @@ dependencies {
|
||||
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
|
||||
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
||||
|
||||
implementation "net.zetetic:android-database-sqlcipher:4.4.3"
|
||||
implementation 'org.signal:android-database-sqlcipher:4.4.3-S3'
|
||||
implementation "androidx.sqlite:sqlite:2.1.0"
|
||||
|
||||
implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
|
||||
@@ -518,67 +552,6 @@ dependencyVerification {
|
||||
configuration = '(play|website)(Prod|Staging)(Debug|Release)RuntimeClasspath'
|
||||
}
|
||||
|
||||
def assembleWebsiteDescriptor = { variant, file ->
|
||||
if (file.exists()) {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
file.eachByte 4096, {bytes, size ->
|
||||
md.update(bytes, 0, size);
|
||||
}
|
||||
|
||||
String digest = md.digest().collect {String.format "%02x", it}.join();
|
||||
String url = variant.productFlavors.get(0).ext.websiteUpdateUrl
|
||||
String apkName = file.getName()
|
||||
|
||||
String descriptor = "{" +
|
||||
"\"versionCode\" : ${canonicalVersionCode * postFixSize + abiPostFix['universal']}," +
|
||||
"\"versionName\" : \"$canonicalVersionName\"," +
|
||||
"\"sha256sum\" : \"$digest\"," +
|
||||
"\"url\" : \"$url/$apkName\"" +
|
||||
"}"
|
||||
|
||||
File descriptorFile = new File(file.getParent(), apkName.replace(".apk", ".json"))
|
||||
|
||||
descriptorFile.write(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
def signProductionRelease = { variant ->
|
||||
variant.outputs.collect { output ->
|
||||
String apkName = output.outputFile.name
|
||||
File inputFile = new File(output.outputFile.path)
|
||||
File outputFile = new File(output.outputFile.parent, apkName.replace('-unsigned', ''))
|
||||
|
||||
new ApkSignerUtil('sun.security.pkcs11.SunPKCS11',
|
||||
'pkcs11.config',
|
||||
'PKCS11',
|
||||
'file:pkcs11.password').calculateSignature(inputFile.getAbsolutePath(),
|
||||
outputFile.getAbsolutePath())
|
||||
|
||||
inputFile.delete()
|
||||
outputFile
|
||||
}
|
||||
}
|
||||
|
||||
task signProductionPlayRelease {
|
||||
doLast {
|
||||
signProductionRelease(android.applicationVariants.find { (it.name == 'playProdRelease') })
|
||||
}
|
||||
}
|
||||
|
||||
task signProductionInternalRelease {
|
||||
doLast {
|
||||
signProductionRelease(android.applicationVariants.find { (it.name == 'internalProdRelease') })
|
||||
}
|
||||
}
|
||||
|
||||
task signProductionWebsiteRelease {
|
||||
doLast {
|
||||
def variant = android.applicationVariants.find { (it.name == 'websiteProdRelease') }
|
||||
File signedRelease = signProductionRelease(variant).find { it.name.contains('universal') }
|
||||
assembleWebsiteDescriptor(variant, signedRelease)
|
||||
}
|
||||
}
|
||||
|
||||
def getLastCommitTimestamp() {
|
||||
if (!(new File('.git').exists())) {
|
||||
return System.currentTimeMillis().toString()
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
-keep class org.sqlite.database.** { *; }
|
||||
|
||||
-keep class net.sqlcipher.** { *; }
|
||||
-dontwarn net.sqlcipher.**
|
||||
-dontwarn net.sqlcipher.**
|
||||
|
||||
-keep class net.zetetic.** { *; }
|
||||
-dontwarn net.zetetic.**
|
||||
|
||||
@@ -9,3 +9,5 @@
|
||||
|
||||
# Protobuf lite
|
||||
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
|
||||
|
||||
-keep class androidx.window.** { *; }
|
||||
@@ -11,9 +11,9 @@ import androidx.annotation.Nullable;
|
||||
import com.facebook.flipper.plugins.databases.DatabaseDescriptor;
|
||||
import com.facebook.flipper.plugins.databases.DatabaseDriver;
|
||||
|
||||
import net.sqlcipher.DatabaseUtils;
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteStatement;
|
||||
import net.zetetic.database.DatabaseUtils;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteStatement;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
@@ -50,11 +50,13 @@ public class FlipperSqlCipherAdapter extends DatabaseDriver<FlipperSqlCipherAdap
|
||||
SignalDatabase keyValueOpenHelper = KeyValueDatabase.getInstance((Application) getContext());
|
||||
SignalDatabase megaphoneOpenHelper = MegaphoneDatabase.getInstance((Application) getContext());
|
||||
SignalDatabase jobManagerOpenHelper = JobDatabase.getInstance((Application) getContext());
|
||||
SignalDatabase metricsOpenHelper = LocalMetricsDatabase.getInstance((Application) getContext());
|
||||
|
||||
return Arrays.asList(new Descriptor(mainOpenHelper),
|
||||
new Descriptor(keyValueOpenHelper),
|
||||
new Descriptor(megaphoneOpenHelper),
|
||||
new Descriptor(jobManagerOpenHelper));
|
||||
new Descriptor(jobManagerOpenHelper),
|
||||
new Descriptor(metricsOpenHelper));
|
||||
} catch (Exception e) {
|
||||
Log.i(TAG, "Unable to use reflection to access raw database.", e);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
tools:replace="android:allowBackup"
|
||||
android:resizeableActivity="true"
|
||||
android:allowBackup="false"
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:largeHeap="true">
|
||||
@@ -104,6 +105,9 @@
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>
|
||||
|
||||
<meta-data android:name="android.supports_size_changes"
|
||||
android:value="true" />
|
||||
|
||||
<meta-data android:name="com.google.android.gms.version"
|
||||
android:value="@integer/google_play_services_version" />
|
||||
|
||||
@@ -117,16 +121,15 @@
|
||||
<activity android:name=".WebRtcCallActivity"
|
||||
android:theme="@style/TextSecure.DarkTheme.WebRTCCall"
|
||||
android:excludeFromRecents="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
||||
android:taskAffinity=".calling"
|
||||
android:resizeableActivity="true"
|
||||
android:launchMode="singleTask"/>
|
||||
|
||||
<activity android:name=".messagerequests.CalleeMustAcceptMessageRequestActivity"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:screenOrientation="portrait"
|
||||
android:noHistory="true"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
@@ -240,6 +243,9 @@
|
||||
<meta-data android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
|
||||
<meta-data android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity android:name=".deeplinks.DeepLinkEntryActivity"
|
||||
@@ -272,6 +278,16 @@
|
||||
<data android:scheme="sgnl"
|
||||
android:host="signal.tube" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="signal.me" />
|
||||
<data android:scheme="sgnl"
|
||||
android:host="signal.me" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".conversation.ConversationActivity"
|
||||
@@ -302,7 +318,8 @@
|
||||
<activity android:name=".messagedetails.MessageDetailsActivity"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
@@ -350,17 +367,18 @@
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".mediasend.MediaSendActivity"
|
||||
android:theme="@style/TextSecure.FullScreenMedia"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
<activity android:name=".mediasend.v2.MediaSelectionActivity"
|
||||
android:theme="@style/TextSecure.FullScreenMedia"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".PassphraseChangeActivity"
|
||||
android:label="@string/AndroidManifest__change_passphrase"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".VerifyIdentityActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".components.settings.app.AppSettingsActivity"
|
||||
@@ -373,6 +391,10 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".components.settings.app.changenumber.ChangeNumberLockActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".components.settings.conversation.ConversationSettingsActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.ConversationSettings"
|
||||
@@ -478,6 +500,7 @@
|
||||
|
||||
<activity android:name="org.thoughtcrime.securesms.webrtc.VoiceCallShare"
|
||||
android:excludeFromRecents="true"
|
||||
android:permission="android.permission.CALL_PHONE"
|
||||
android:theme="@style/NoAnimation.Theme.BlackScreen"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||
|
||||
BIN
app/src/main/assets/fonts/Inter-Medium.otf
Normal file
BIN
app/src/main/assets/fonts/Inter-Medium.otf
Normal file
Binary file not shown.
@@ -60,6 +60,7 @@ import androidx.camera.core.impl.LensFacingConverter;
|
||||
import androidx.camera.core.impl.utils.executor.CameraXExecutors;
|
||||
import androidx.camera.core.impl.utils.futures.FutureCallback;
|
||||
import androidx.camera.core.impl.utils.futures.Futures;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
@@ -130,6 +131,11 @@ public final class SignalCameraView extends FrameLayout {
|
||||
// For accessibility event
|
||||
private MotionEvent mUpEvent;
|
||||
|
||||
// BEGIN Custom Signal Code Block
|
||||
private Consumer<Throwable> errorConsumer;
|
||||
private Throwable pendingError;
|
||||
// END Custom Signal Code Block
|
||||
|
||||
public SignalCameraView(@NonNull Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
@@ -167,14 +173,32 @@ public final class SignalCameraView extends FrameLayout {
|
||||
* androidx.lifecycle.Lifecycle.State#DESTROYED} state.
|
||||
* @throws IllegalStateException if camera permissions are not granted.
|
||||
*/
|
||||
// BEGIN Custom Signal Code Block
|
||||
|
||||
@RequiresPermission(permission.CAMERA)
|
||||
public void bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner) {
|
||||
public void bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner, Consumer<Throwable> errorConsumer) {
|
||||
mCameraModule.bindToLifecycle(lifecycleOwner);
|
||||
this.errorConsumer = errorConsumer;
|
||||
if (pendingError != null) {
|
||||
errorConsumer.accept(pendingError);
|
||||
}
|
||||
}
|
||||
// END Custom Signal Code Block
|
||||
|
||||
|
||||
private void init(Context context, @Nullable AttributeSet attrs) {
|
||||
addView(mPreviewView = new PreviewView(getContext()), 0 /* view position */);
|
||||
mCameraModule = new SignalCameraXModule(this);
|
||||
|
||||
// Begin custom signal code block
|
||||
mPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
|
||||
mCameraModule = new SignalCameraXModule(this, error -> {
|
||||
if (errorConsumer != null) {
|
||||
errorConsumer.accept(error);
|
||||
} else {
|
||||
pendingError = error;
|
||||
}
|
||||
});
|
||||
// End custom signal code block
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CameraView);
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.camera.core.impl.utils.executor.CameraXExecutors;
|
||||
import androidx.camera.core.impl.utils.futures.FutureCallback;
|
||||
import androidx.camera.core.impl.utils.futures.Futures;
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.core.util.Preconditions;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.lifecycle.LifecycleObserver;
|
||||
@@ -123,7 +124,9 @@ final class SignalCameraXModule {
|
||||
@Nullable
|
||||
ProcessCameraProvider mCameraProvider;
|
||||
|
||||
SignalCameraXModule(SignalCameraView view) {
|
||||
// BEGIN Custom Signal Code Block
|
||||
SignalCameraXModule(SignalCameraView view, Consumer<Throwable> errorConsumer) {
|
||||
// END Custom Signal Code Block
|
||||
mCameraView = view;
|
||||
|
||||
Futures.addCallback(ProcessCameraProvider.getInstance(view.getContext()),
|
||||
@@ -141,7 +144,9 @@ final class SignalCameraXModule {
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
throw new RuntimeException("CameraX failed to initialize.", t);
|
||||
// BEGIN Custom Signal Code Block
|
||||
errorConsumer.accept(t);
|
||||
// END Custom Signal Code Block
|
||||
}
|
||||
}, CameraXExecutors.mainThreadExecutor());
|
||||
|
||||
@@ -222,17 +227,10 @@ final class SignalCameraXModule {
|
||||
// End Signal Custom Code Block
|
||||
|
||||
Rational targetAspectRatio;
|
||||
if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) {
|
||||
// Begin Signal Custom Code Block
|
||||
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_4_3, isDisplayPortrait));
|
||||
// End Signal Custom Code Block
|
||||
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3;
|
||||
} else {
|
||||
// Begin Signal Custom Code Block
|
||||
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
|
||||
// End Signal Custom Code Block
|
||||
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
|
||||
}
|
||||
// Begin Signal Custom Code Block
|
||||
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
|
||||
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
|
||||
// End Signal Custom Code Block
|
||||
|
||||
// Begin Signal Custom Code Block
|
||||
mImageCaptureBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode());
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
|
||||
public final class AppCapabilities {
|
||||
@@ -8,15 +7,18 @@ public final class AppCapabilities {
|
||||
private AppCapabilities() {
|
||||
}
|
||||
|
||||
private static final boolean UUID_CAPABLE = false;
|
||||
private static final boolean GV2_CAPABLE = true;
|
||||
private static final boolean GV1_MIGRATION = true;
|
||||
private static final boolean UUID_CAPABLE = false;
|
||||
private static final boolean GV2_CAPABLE = true;
|
||||
private static final boolean GV1_MIGRATION = true;
|
||||
private static final boolean ANNOUNCEMENT_GROUPS = true;
|
||||
private static final boolean SENDER_KEY = true;
|
||||
private static final boolean CHANGE_NUMBER = true;
|
||||
|
||||
/**
|
||||
* @param storageCapable Whether or not the user can use storage service. This is another way of
|
||||
* asking if the user has set a Signal PIN or not.
|
||||
*/
|
||||
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, FeatureFlags.senderKey());
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,18 +27,17 @@ import androidx.multidex.MultiDexApplication;
|
||||
|
||||
import com.google.android.gms.security.ProviderInstaller;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.conscrypt.Conscrypt;
|
||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.AndroidLogger;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.logging.PersistentLogger;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.LogDatabase;
|
||||
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
@@ -56,7 +55,7 @@ import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.LogSecretProvider;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
@@ -74,9 +73,9 @@ import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.ByteUnit;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
@@ -114,6 +113,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
public void onCreate() {
|
||||
Tracer.getInstance().start("Application#onCreate()");
|
||||
AppStartup.getInstance().onApplicationCreate();
|
||||
SignalLocalMetrics.ColdStart.start();
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
@@ -124,12 +124,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
super.onCreate();
|
||||
|
||||
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("sqlcipher-init", () -> SqlCipherLibraryLoader.load(this))
|
||||
.addBlocking("logging", () -> {
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
})
|
||||
.addBlocking("crash-handling", this::initializeCrashHandling)
|
||||
.addBlocking("sqlcipher-init", () -> SqlCipherLibraryLoader.load(this))
|
||||
.addBlocking("rx-init", () -> {
|
||||
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
|
||||
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
|
||||
@@ -156,6 +156,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
})
|
||||
.addBlocking("blob-provider", this::initializeBlobProvider)
|
||||
.addBlocking("feature-flags", FeatureFlags::init)
|
||||
.addNonBlocking(this::cleanAvatarStorage)
|
||||
.addNonBlocking(this::initializeRevealableMessageManager)
|
||||
.addNonBlocking(this::initializePendingRetryReceiptManager)
|
||||
.addNonBlocking(this::initializeGcmCheck)
|
||||
@@ -169,6 +170,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
|
||||
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
|
||||
.addNonBlocking(EmojiSource::refresh)
|
||||
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
|
||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
||||
@@ -178,6 +180,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
SignalLocalMetrics.ColdStart.onApplicationCreateFinished();
|
||||
Tracer.getInstance().end("Application#onCreate()");
|
||||
}
|
||||
|
||||
@@ -248,10 +251,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
|
||||
private void initializeLogging() {
|
||||
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME, FeatureFlags.internalUser() ? 15 : 7, ByteUnit.KILOBYTES.toBytes(300));
|
||||
persistentLogger = new PersistentLogger(this);
|
||||
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
|
||||
|
||||
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
|
||||
|
||||
SignalExecutors.UNBOUNDED.execute(() -> LogDatabase.getInstance(this).trimToSize());
|
||||
}
|
||||
|
||||
private void initializeCrashHandling() {
|
||||
@@ -379,6 +384,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
BlobProvider.getInstance().initialize(this);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void cleanAvatarStorage() {
|
||||
AvatarPickerStorage.cleanOrphans(this);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void initializeCleanup() {
|
||||
int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments();
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.bumptech.glide.request.target.Target;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
@@ -71,12 +72,14 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
|
||||
}
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
ImageView avatar = findViewById(R.id.avatar);
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
EmojiTextView title = findViewById(R.id.title);
|
||||
ImageView avatar = findViewById(R.id.avatar);
|
||||
|
||||
setSupportActionBar(toolbar);
|
||||
|
||||
requireSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
requireSupportActionBar().setDisplayShowTitleEnabled(false);
|
||||
|
||||
Context context = getApplicationContext();
|
||||
RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA));
|
||||
@@ -122,7 +125,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
}
|
||||
});
|
||||
|
||||
toolbar.setTitle(recipient.getDisplayName(context));
|
||||
title.setText(recipient.getDisplayName(context));
|
||||
});
|
||||
|
||||
FullscreenHelper fullscreenHelper = new FullscreenHelper(this);
|
||||
|
||||
@@ -86,7 +86,8 @@ public abstract class BaseActivity extends AppCompatActivity {
|
||||
int appCompatNightMode = getDelegate().getLocalNightMode() != AppCompatDelegate.MODE_NIGHT_UNSPECIFIED ? getDelegate().getLocalNightMode()
|
||||
: AppCompatDelegate.getDefaultNightMode();
|
||||
|
||||
configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | mapNightModeToConfigurationUiMode(newBase, appCompatNightMode);
|
||||
configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | mapNightModeToConfigurationUiMode(newBase, appCompatNightMode);
|
||||
configuration.orientation = Configuration.ORIENTATION_UNDEFINED;
|
||||
|
||||
applyOverrideConfiguration(configuration);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable;
|
||||
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
@@ -24,31 +26,29 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable {
|
||||
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable, Multiselectable {
|
||||
void bind(@NonNull LifecycleOwner lifecycleOwner,
|
||||
@NonNull ConversationMessage messageRecord,
|
||||
@NonNull Optional<MessageRecord> previousMessageRecord,
|
||||
@NonNull Optional<MessageRecord> nextMessageRecord,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<ConversationMessage> batchSelected,
|
||||
@NonNull Set<MultiselectPart> batchSelected,
|
||||
@NonNull Recipient recipients,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulseMention,
|
||||
boolean hasWallpaper,
|
||||
boolean isMessageRequestAccepted,
|
||||
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
|
||||
boolean canPlayInline,
|
||||
@NonNull Colorizer colorizer);
|
||||
|
||||
ConversationMessage getConversationMessage();
|
||||
@NonNull ConversationMessage getConversationMessage();
|
||||
|
||||
void setEventListener(@Nullable EventListener listener);
|
||||
|
||||
@@ -87,6 +87,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onPlayInlineContent(ConversationMessage conversationMessage);
|
||||
void onInMemoryMessageClicked(@NonNull InMemoryMessageRecord messageRecord);
|
||||
void onViewGroupDescriptionChange(@Nullable GroupId groupId, @NonNull String description, boolean isMessageRequestAccepted);
|
||||
void onChangeNumberUpdateContact(@NonNull Recipient recipient);
|
||||
|
||||
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||
boolean onUrlClicked(@NonNull String url);
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Base activity container for selecting a list of contacts.
|
||||
@@ -122,8 +123,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
|
||||
return true;
|
||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -26,6 +26,7 @@ import android.database.Cursor;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -37,6 +38,7 @@ import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
@@ -53,6 +55,7 @@ import androidx.transition.TransitionManager;
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.chip.ChipGroup;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -88,6 +91,7 @@ import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Fragment for selecting a one or more contacts from a list.
|
||||
@@ -176,12 +180,16 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context;
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnSelectionLimitReachedListener) {
|
||||
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
|
||||
}
|
||||
|
||||
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
|
||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +268,9 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
recyclerView.setClipToPadding(recyclerViewClipping);
|
||||
|
||||
swipeRefresh.setEnabled(arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true)));
|
||||
boolean isRefreshable = arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true));
|
||||
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
|
||||
swipeRefresh.setEnabled(isRefreshable);
|
||||
|
||||
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
|
||||
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
|
||||
@@ -436,6 +446,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
|
||||
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
|
||||
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
FragmentActivity activity = requireActivity();
|
||||
@@ -566,28 +580,32 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
if (onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null)) {
|
||||
markContactSelected(selected);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selected);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
markContactSelected(selected);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
} else {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
|
||||
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
|
||||
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (onContactSelectedListener != null) {
|
||||
if (onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber())) {
|
||||
markContactSelected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber(), allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
markContactSelected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
@@ -680,6 +698,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
@Override
|
||||
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
|
||||
if (getView() == null || !requireView().isAttachedToWindow()) {
|
||||
Log.w(TAG, "Fragment's view was detached before the animation completed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (view == chip && transitionType == LayoutTransition.APPEARING) {
|
||||
chipGroup.getLayoutTransition().removeTransitionListener(this);
|
||||
registerChipRecipientObserver(chip, recipient.live());
|
||||
@@ -742,8 +765,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
public interface OnContactSelectedListener {
|
||||
/** @return True if the contact is allowed to be selected, otherwise false. */
|
||||
boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number);
|
||||
/** Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it. */
|
||||
void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback);
|
||||
void onContactDeselected(Optional<RecipientId> recipientId, String number);
|
||||
void onSelectionChanged();
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
|
||||
@@ -3,9 +3,7 @@ package org.thoughtcrime.securesms;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.View.OnClickListener;
|
||||
@@ -14,12 +12,12 @@ import android.view.animation.Animation;
|
||||
import android.view.animation.AnimationUtils;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.AnimRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
@@ -34,27 +32,25 @@ import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class InviteActivity extends PassphraseRequiredActivity implements ContactSelectionListFragment.OnContactSelectedListener {
|
||||
|
||||
private ContactSelectionListFragment contactsFragment;
|
||||
private EditText inviteText;
|
||||
private ViewGroup smsSendFrame;
|
||||
private Button smsSendButton;
|
||||
private Animation slideInAnimation;
|
||||
private Animation slideOutAnimation;
|
||||
private DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme();
|
||||
private Toolbar primaryToolbar;
|
||||
private ContactSelectionListFragment contactsFragment;
|
||||
private EditText inviteText;
|
||||
private ViewGroup smsSendFrame;
|
||||
private Button smsSendButton;
|
||||
private Animation slideInAnimation;
|
||||
private Animation slideOutAnimation;
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme();
|
||||
|
||||
@Override
|
||||
protected void onPreCreate() {
|
||||
@@ -82,7 +78,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
}
|
||||
|
||||
private void initializeAppBar() {
|
||||
primaryToolbar = findViewById(R.id.toolbar);
|
||||
final Toolbar primaryToolbar = findViewById(R.id.toolbar);
|
||||
setSupportActionBar(primaryToolbar);
|
||||
|
||||
assert getSupportActionBar() != null;
|
||||
@@ -96,9 +92,9 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
slideOutAnimation = loadAnimation(R.anim.slide_to_bottom);
|
||||
|
||||
View shareButton = findViewById(R.id.share_button);
|
||||
Button smsButton = findViewById(R.id.sms_button);
|
||||
TextView shareText = findViewById(R.id.share_text);
|
||||
View smsButton = findViewById(R.id.sms_button);
|
||||
Button smsCancelButton = findViewById(R.id.cancel_sms_button);
|
||||
Toolbar smsToolbar = findViewById(R.id.sms_send_frame_toolbar);
|
||||
ContactFilterView contactFilter = findViewById(R.id.contact_filter_edit_text);
|
||||
|
||||
inviteText = findViewById(R.id.invite_text);
|
||||
@@ -120,15 +116,14 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
smsCancelButton.setOnClickListener(new SmsCancelClickListener());
|
||||
smsSendButton.setOnClickListener(new SmsSendClickListener());
|
||||
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
|
||||
smsToolbar.setNavigationIcon(R.drawable.ic_search_conversation_24);
|
||||
|
||||
if (Util.isDefaultSmsProvider(this)) {
|
||||
shareButton.setOnClickListener(new ShareClickListener());
|
||||
smsButton.setOnClickListener(new SmsClickListener());
|
||||
} else {
|
||||
shareButton.setVisibility(View.GONE);
|
||||
smsButton.setOnClickListener(new ShareClickListener());
|
||||
smsButton.setText(R.string.InviteActivity_share);
|
||||
smsButton.setVisibility(View.GONE);
|
||||
shareText.setText(R.string.InviteActivity_share);
|
||||
shareButton.setOnClickListener(new ShareClickListener());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +134,9 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
|
||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
|
||||
return true;
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -161,9 +156,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
}
|
||||
|
||||
private void updateSmsButtonText(int count) {
|
||||
smsSendButton.setText(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_to_friends,
|
||||
count,
|
||||
count));
|
||||
smsSendButton.setText(getResources().getString(R.string.InviteActivity_send_sms, count));
|
||||
smsSendButton.setEnabled(count > 0);
|
||||
}
|
||||
|
||||
@@ -175,43 +168,21 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
}
|
||||
}
|
||||
|
||||
@Override public boolean onSupportNavigateUp() {
|
||||
if (smsSendFrame.getVisibility() == View.VISIBLE) {
|
||||
cancelSmsSelection();
|
||||
return false;
|
||||
} else {
|
||||
return super.onSupportNavigateUp();
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelSmsSelection() {
|
||||
setPrimaryColorsToolbarNormal();
|
||||
contactsFragment.reset();
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
||||
ViewUtil.animateOut(smsSendFrame, slideOutAnimation, View.GONE);
|
||||
}
|
||||
|
||||
private void setPrimaryColorsToolbarNormal() {
|
||||
primaryToolbar.setBackgroundColor(0);
|
||||
primaryToolbar.getNavigationIcon().setColorFilter(null);
|
||||
primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_primary));
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
WindowUtil.setStatusBarColor(getWindow(), ThemeUtil.getThemedColor(this, android.R.attr.statusBarColor));
|
||||
getWindow().setNavigationBarColor(ThemeUtil.getThemedColor(this, android.R.attr.navigationBarColor));
|
||||
WindowUtil.setLightStatusBarFromTheme(this);
|
||||
}
|
||||
|
||||
WindowUtil.setLightNavigationBarFromTheme(this);
|
||||
}
|
||||
|
||||
private void setPrimaryColorsToolbarForSms() {
|
||||
primaryToolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.core_ultramarine));
|
||||
primaryToolbar.getNavigationIcon().setColorFilter(ContextCompat.getColor(this, R.color.signal_text_toolbar_subtitle), PorterDuff.Mode.SRC_IN);
|
||||
primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_toolbar_title));
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
WindowUtil.setStatusBarColor(getWindow(), ContextCompat.getColor(this, R.color.core_ultramarine));
|
||||
WindowUtil.clearLightStatusBar(getWindow());
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 27) {
|
||||
getWindow().setNavigationBarColor(ContextCompat.getColor(this, R.color.core_ultramarine));
|
||||
WindowUtil.clearLightNavigationBar(getWindow());
|
||||
}
|
||||
}
|
||||
|
||||
private class ShareClickListener implements OnClickListener {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
@@ -230,7 +201,6 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
private class SmsClickListener implements OnClickListener {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
setPrimaryColorsToolbarForSms();
|
||||
ViewUtil.animateIn(smsSendFrame, slideInAnimation);
|
||||
}
|
||||
}
|
||||
@@ -282,7 +252,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
Recipient recipient = Recipient.resolved(recipientId);
|
||||
int subscriptionId = recipient.getDefaultSubscriptionId().or(-1);
|
||||
|
||||
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null);
|
||||
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null, null);
|
||||
|
||||
if (recipient.getContactUri() != null) {
|
||||
DatabaseFactory.getRecipientDatabase(context).setHasSentInvite(recipient.getId());
|
||||
|
||||
@@ -49,6 +49,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
|
||||
handleGroupLinkInIntent(getIntent());
|
||||
handleProxyInIntent(getIntent());
|
||||
handleSignalMeIntent(getIntent());
|
||||
|
||||
CachedInflater.from(this).clear();
|
||||
}
|
||||
@@ -65,6 +66,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
super.onNewIntent(intent);
|
||||
handleGroupLinkInIntent(intent);
|
||||
handleProxyInIntent(intent);
|
||||
handleSignalMeIntent(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -115,6 +117,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSignalMeIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
CommunicationActions.handlePotentialSignalMeUrl(this, data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||
return mediaController;
|
||||
|
||||
@@ -171,6 +171,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
initializeObservers();
|
||||
}
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Activity container for starting a new conversation.
|
||||
@@ -60,7 +61,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
|
||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||
if (recipientId.isPresent()) {
|
||||
launch(Recipient.resolved(recipientId.get()));
|
||||
} else {
|
||||
@@ -94,7 +95,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -35,7 +35,6 @@ import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.View.OnClickListener;
|
||||
import android.view.WindowManager;
|
||||
import android.view.animation.Animation;
|
||||
import android.view.animation.BounceInterpolator;
|
||||
import android.view.animation.TranslateAnimation;
|
||||
@@ -51,6 +50,7 @@ import androidx.biometric.BiometricManager;
|
||||
import androidx.biometric.BiometricManager.Authenticators;
|
||||
import androidx.biometric.BiometricPrompt;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.components.AnimatingToggle;
|
||||
@@ -98,13 +98,16 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
private boolean hadFailure;
|
||||
private boolean alreadyShown;
|
||||
|
||||
private final Runnable resumeScreenLockRunnable = () -> {
|
||||
resumeScreenLock(!alreadyShown);
|
||||
alreadyShown = true;
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Log.i(TAG, "onCreate()");
|
||||
dynamicTheme.onCreate(this);
|
||||
dynamicLanguage.onCreate(this);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setContentView(R.layout.prompt_passphrase_activity);
|
||||
@@ -129,11 +132,20 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
setLockTypeVisibility();
|
||||
|
||||
if (TextSecurePreferences.isScreenLockEnabled(this) && !authenticated && !hadFailure) {
|
||||
resumeScreenLock(!alreadyShown);
|
||||
alreadyShown = true;
|
||||
ThreadUtil.postToMain(resumeScreenLockRunnable);
|
||||
}
|
||||
|
||||
hadFailure = false;
|
||||
|
||||
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
|
||||
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
ThreadUtil.cancelRunnableOnMain(resumeScreenLockRunnable);
|
||||
biometricPrompt.cancelAuthentication();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -388,9 +400,6 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
handleAuthenticated();
|
||||
|
||||
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
|
||||
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
@@ -412,7 +421,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
@Override
|
||||
public void onAnimationEnd(Animation animation) {
|
||||
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
|
||||
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
|
||||
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.devicetransfer.TransferStatus;
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
|
||||
@@ -50,6 +51,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
private static final int STATE_CREATE_SIGNAL_PIN = 7;
|
||||
private static final int STATE_TRANSFER_ONGOING = 8;
|
||||
private static final int STATE_TRANSFER_LOCKED = 9;
|
||||
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
|
||||
|
||||
private SignalServiceNetworkAccess networkAccess;
|
||||
private BroadcastReceiver clearKeyReceiver;
|
||||
@@ -153,6 +155,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
case STATE_CREATE_PROFILE_NAME: return getCreateProfileNameIntent();
|
||||
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
|
||||
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
|
||||
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -176,6 +179,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_TRANSFER_ONGOING;
|
||||
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
|
||||
return STATE_TRANSFER_LOCKED;
|
||||
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class) {
|
||||
return STATE_CHANGE_NUMBER_LOCK;
|
||||
} else {
|
||||
return STATE_NORMAL;
|
||||
}
|
||||
@@ -243,6 +248,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return MainActivity.clearTop(this);
|
||||
}
|
||||
|
||||
private Intent getChangeNumberLockIntent() {
|
||||
return ChangeNumberLockActivity.createIntent(this);
|
||||
}
|
||||
|
||||
private Intent getRoutedIntent(Class<?> destination, @Nullable Intent nextIntent) {
|
||||
final Intent intent = new Intent(this, destination);
|
||||
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
|
||||
|
||||
@@ -43,32 +43,38 @@ import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.animation.Animation;
|
||||
import android.view.animation.AnticipateInterpolator;
|
||||
import android.view.animation.OvershootInterpolator;
|
||||
import android.view.animation.ScaleAnimation;
|
||||
import android.widget.CompoundButton;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextSwitcher;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.view.OneShotPreDrawListener;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ShapeScrim;
|
||||
import org.thoughtcrime.securesms.components.camera.CameraView;
|
||||
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
@@ -79,6 +85,7 @@ import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
@@ -109,13 +116,13 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
private static final String IDENTITY_EXTRA = "recipient_identity";
|
||||
private static final String VERIFIED_EXTRA = "verified_state";
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicTheme();
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
|
||||
private final VerifyDisplayFragment displayFragment = new VerifyDisplayFragment();
|
||||
private final VerifyScanFragment scanFragment = new VerifyScanFragment();
|
||||
|
||||
public static Intent newIntent(@NonNull Context context,
|
||||
@NonNull IdentityDatabase.IdentityRecord identityRecord)
|
||||
@NonNull IdentityRecord identityRecord)
|
||||
{
|
||||
return newIntent(context,
|
||||
identityRecord.getRecipientId(),
|
||||
@@ -124,7 +131,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
}
|
||||
|
||||
public static Intent newIntent(@NonNull Context context,
|
||||
@NonNull IdentityDatabase.IdentityRecord identityRecord,
|
||||
@NonNull IdentityRecord identityRecord,
|
||||
boolean verified)
|
||||
{
|
||||
return newIntent(context,
|
||||
@@ -154,9 +161,6 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle state, boolean ready) {
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setTitle(R.string.AndroidManifest__verify_safety_number);
|
||||
|
||||
Bundle extras = new Bundle();
|
||||
extras.putParcelable(VerifyDisplayFragment.RECIPIENT_ID, getIntent().getParcelableExtra(RECIPIENT_EXTRA));
|
||||
extras.putParcelable(VerifyDisplayFragment.REMOTE_IDENTITY, getIntent().getParcelableExtra(IDENTITY_EXTRA));
|
||||
@@ -208,12 +212,13 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
.execute();
|
||||
}
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
}
|
||||
|
||||
public static class VerifyDisplayFragment extends Fragment implements CompoundButton.OnCheckedChangeListener {
|
||||
public static class VerifyDisplayFragment extends Fragment implements ViewTreeObserver.OnScrollChangedListener {
|
||||
|
||||
public static final String RECIPIENT_ID = "recipient_id";
|
||||
public static final String REMOTE_NUMBER = "remote_number";
|
||||
@@ -227,28 +232,41 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
private IdentityKey remoteIdentity;
|
||||
private Fingerprint fingerprint;
|
||||
|
||||
private Toolbar toolbar;
|
||||
private ScrollView scrollView;
|
||||
private View container;
|
||||
private View numbersContainer;
|
||||
private View loading;
|
||||
private View qrCodeContainer;
|
||||
private ImageView qrCode;
|
||||
private ImageView qrVerified;
|
||||
private TextView tapLabel;
|
||||
private TextSwitcher tapLabel;
|
||||
private TextView description;
|
||||
private View.OnClickListener clickListener;
|
||||
private SwitchCompat verified;
|
||||
private Button verifyButton;
|
||||
private View toolbarShadow;
|
||||
private View bottomShadow;
|
||||
|
||||
private TextView[] codes = new TextView[12];
|
||||
private boolean animateSuccessOnDraw = false;
|
||||
private boolean animateFailureOnDraw = false;
|
||||
private boolean currentVerifiedState = false;
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
||||
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_display_fragment);
|
||||
this.toolbar = container.findViewById(R.id.toolbar);
|
||||
this.scrollView = container.findViewById(R.id.scroll_view);
|
||||
this.numbersContainer = container.findViewById(R.id.number_table);
|
||||
this.loading = container.findViewById(R.id.loading);
|
||||
this.qrCodeContainer = container.findViewById(R.id.qr_code_container);
|
||||
this.qrCode = container.findViewById(R.id.qr_code);
|
||||
this.verified = container.findViewById(R.id.verified_switch);
|
||||
this.verifyButton = container.findViewById(R.id.verify_button);
|
||||
this.qrVerified = container.findViewById(R.id.qr_verified);
|
||||
this.description = container.findViewById(R.id.description);
|
||||
this.tapLabel = container.findViewById(R.id.tap_label);
|
||||
this.toolbarShadow = container.findViewById(R.id.toolbar_shadow);
|
||||
this.bottomShadow = container.findViewById(R.id.verify_identity_bottom_shadow);
|
||||
this.codes[0] = container.findViewById(R.id.code_first);
|
||||
this.codes[1] = container.findViewById(R.id.code_second);
|
||||
this.codes[2] = container.findViewById(R.id.code_third);
|
||||
@@ -262,15 +280,25 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
this.codes[10] = container.findViewById(R.id.code_eleventh);
|
||||
this.codes[11] = container.findViewById(R.id.code_twelth);
|
||||
|
||||
this.qrCode.setOnClickListener(clickListener);
|
||||
this.qrCodeContainer.setOnClickListener(clickListener);
|
||||
this.registerForContextMenu(numbersContainer);
|
||||
|
||||
this.verified.setChecked(getArguments().getBoolean(VERIFIED_STATE, false));
|
||||
this.verified.setOnCheckedChangeListener(this);
|
||||
updateVerifyButton(getArguments().getBoolean(VERIFIED_STATE, false), false);
|
||||
this.verifyButton.setOnClickListener((button -> updateVerifyButton(!currentVerifiedState, true)));
|
||||
|
||||
this.scrollView.getViewTreeObserver().addOnScrollChangedListener(this);
|
||||
|
||||
((AppCompatActivity)requireActivity()).setSupportActionBar(toolbar);
|
||||
((AppCompatActivity)requireActivity()).setTitle(R.string.AndroidManifest__verify_safety_number);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@Override public void onDestroyView() {
|
||||
this.scrollView.getViewTreeObserver().removeOnScrollChangedListener(this);
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle bundle) {
|
||||
super.onCreate(bundle);
|
||||
@@ -326,6 +354,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Fingerprint fingerprint) {
|
||||
if (getActivity() == null) return;
|
||||
VerifyDisplayFragment.this.fingerprint = fingerprint;
|
||||
setFingerprintViews(fingerprint, true);
|
||||
getActivity().supportInvalidateOptionsMenu();
|
||||
@@ -352,6 +381,8 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
animateFailureOnDraw = false;
|
||||
animateVerifiedFailure();
|
||||
}
|
||||
|
||||
ThreadUtil.postToMain(this::onScrollChanged);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -409,9 +440,11 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
} else {
|
||||
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_an_old_version_of_signal, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
this.animateFailureOnDraw = true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, e);
|
||||
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number, Toast.LENGTH_LONG).show();
|
||||
this.animateFailureOnDraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +512,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
}
|
||||
|
||||
private void setRecipientText(Recipient recipient) {
|
||||
description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__if_you_wish_to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
|
||||
description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
|
||||
description.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
}
|
||||
|
||||
@@ -500,9 +533,11 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
if (animate) {
|
||||
ViewUtil.fadeIn(qrCode, 1000);
|
||||
ViewUtil.fadeIn(tapLabel, 1000);
|
||||
ViewUtil.fadeOut(loading, 300, View.GONE);
|
||||
} else {
|
||||
qrCode.setVisibility(View.VISIBLE);
|
||||
tapLabel.setVisibility(View.VISIBLE);
|
||||
loading.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +593,8 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
qrVerified.setImageBitmap(qrSuccess);
|
||||
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.MULTIPLY);
|
||||
|
||||
tapLabel.setText(getString(R.string.verify_display_fragment__successful_match));
|
||||
|
||||
animateVerified();
|
||||
}
|
||||
|
||||
@@ -568,6 +605,8 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
qrVerified.setImageBitmap(qrSuccess);
|
||||
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.MULTIPLY);
|
||||
|
||||
tapLabel.setText(getString(R.string.verify_display_fragment__failed_to_verify_safety_number));
|
||||
|
||||
animateVerified();
|
||||
}
|
||||
|
||||
@@ -575,7 +614,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
ScaleAnimation scaleAnimation = new ScaleAnimation(0, 1, 0, 1,
|
||||
ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
|
||||
ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
|
||||
scaleAnimation.setInterpolator(new OvershootInterpolator());
|
||||
scaleAnimation.setInterpolator(new FastOutSlowInInterpolator());
|
||||
scaleAnimation.setDuration(800);
|
||||
scaleAnimation.setAnimationListener(new Animation.AnimationListener() {
|
||||
@Override
|
||||
@@ -593,6 +632,9 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
scaleAnimation.setInterpolator(new AnticipateInterpolator());
|
||||
scaleAnimation.setDuration(500);
|
||||
ViewUtil.animateOut(qrVerified, scaleAnimation, View.GONE);
|
||||
ViewUtil.fadeIn(qrCode, 800);
|
||||
qrCodeContainer.setEnabled(true);
|
||||
tapLabel.setText(getString(R.string.verify_display_fragment__tap_to_scan));
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
@@ -601,40 +643,70 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
public void onAnimationRepeat(Animation animation) {}
|
||||
});
|
||||
|
||||
ViewUtil.fadeOut(qrCode, 200, View.INVISIBLE);
|
||||
ViewUtil.animateIn(qrVerified, scaleAnimation);
|
||||
qrCodeContainer.setEnabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, final boolean isChecked) {
|
||||
final Recipient recipient = this.recipient.get();
|
||||
final RecipientId recipientId = recipient.getId();
|
||||
private void updateVerifyButton(boolean verified, boolean update) {
|
||||
currentVerifiedState = verified;
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
if (isChecked) {
|
||||
Log.i(TAG, "Saving identity: " + recipientId);
|
||||
DatabaseFactory.getIdentityDatabase(getActivity())
|
||||
.saveIdentity(recipientId,
|
||||
remoteIdentity,
|
||||
VerifiedStatus.VERIFIED, false,
|
||||
System.currentTimeMillis(), true);
|
||||
} else {
|
||||
DatabaseFactory.getIdentityDatabase(getActivity())
|
||||
.setVerified(recipientId,
|
||||
remoteIdentity,
|
||||
VerifiedStatus.DEFAULT);
|
||||
if (verified) {
|
||||
verifyButton.setText(R.string.verify_display_fragment__clear_verification);
|
||||
} else {
|
||||
verifyButton.setText(R.string.verify_display_fragment__mark_as_verified);
|
||||
}
|
||||
|
||||
if (update) {
|
||||
final RecipientId recipientId = recipient.getId();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
if (verified) {
|
||||
Log.i(TAG, "Saving identity: " + recipientId);
|
||||
ApplicationDependencies.getIdentityStore()
|
||||
.saveIdentityWithoutSideEffects(recipientId,
|
||||
remoteIdentity,
|
||||
VerifiedStatus.VERIFIED,
|
||||
false,
|
||||
System.currentTimeMillis(),
|
||||
true);
|
||||
} else {
|
||||
ApplicationDependencies.getIdentityStore().setVerified(recipientId, remoteIdentity, VerifiedStatus.DEFAULT);
|
||||
}
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.add(new MultiDeviceVerifiedUpdateJob(recipientId,
|
||||
remoteIdentity,
|
||||
verified ? VerifiedStatus.VERIFIED
|
||||
: VerifiedStatus.DEFAULT));
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
|
||||
IdentityUtil.markIdentityVerified(getActivity(), recipient.get(), verified, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.add(new MultiDeviceVerifiedUpdateJob(recipientId,
|
||||
remoteIdentity,
|
||||
isChecked ? VerifiedStatus.VERIFIED
|
||||
: VerifiedStatus.DEFAULT));
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
|
||||
IdentityUtil.markIdentityVerified(getActivity(), recipient, isChecked, false);
|
||||
@Override public void onScrollChanged() {
|
||||
if (scrollView.canScrollVertically(-1)) {
|
||||
if (toolbarShadow.getVisibility() != View.VISIBLE) {
|
||||
ViewUtil.fadeIn(toolbarShadow, 250);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (toolbarShadow.getVisibility() != View.GONE) {
|
||||
ViewUtil.fadeOut(toolbarShadow, 250);
|
||||
}
|
||||
}
|
||||
|
||||
if (scrollView.canScrollVertically(1)) {
|
||||
if (bottomShadow.getVisibility() != View.VISIBLE) {
|
||||
ViewUtil.fadeIn(bottomShadow, 250);
|
||||
}
|
||||
} else {
|
||||
ViewUtil.fadeOut(bottomShadow, 250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,12 +714,23 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
|
||||
private View container;
|
||||
private CameraView cameraView;
|
||||
private ShapeScrim cameraScrim;
|
||||
private ImageView cameraMarks;
|
||||
private ScanningThread scanningThread;
|
||||
private ScanListener scanListener;
|
||||
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
||||
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
|
||||
this.cameraView = container.findViewById(R.id.scanner);
|
||||
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
|
||||
this.cameraView = container.findViewById(R.id.scanner);
|
||||
this.cameraScrim = container.findViewById(R.id.camera_scrim);
|
||||
this.cameraMarks = container.findViewById(R.id.camera_marks);
|
||||
|
||||
OneShotPreDrawListener.add(cameraScrim, () -> {
|
||||
int width = cameraScrim.getScrimWidth();
|
||||
int height = cameraScrim.getScrimHeight();
|
||||
|
||||
ViewUtil.updateLayoutParams(cameraMarks, width, height);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
@@ -684,5 +767,4 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -17,28 +17,39 @@
|
||||
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.PictureInPictureParams;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Rect;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Rational;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.window.DisplayFeature;
|
||||
import androidx.window.FoldingFeature;
|
||||
import androidx.window.WindowLayoutInfo;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
|
||||
@@ -49,6 +60,7 @@ import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeN
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
|
||||
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
@@ -60,17 +72,19 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
|
||||
|
||||
@@ -87,11 +101,14 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
|
||||
private DeviceOrientationMonitor deviceOrientationMonitor;
|
||||
|
||||
private FullscreenHelper fullscreenHelper;
|
||||
private WebRtcCallView callScreen;
|
||||
private TooltipPopup videoTooltip;
|
||||
private WebRtcCallViewModel viewModel;
|
||||
private boolean enableVideoIfAvailable;
|
||||
private FullscreenHelper fullscreenHelper;
|
||||
private WebRtcCallView callScreen;
|
||||
private TooltipPopup videoTooltip;
|
||||
private WebRtcCallViewModel viewModel;
|
||||
private boolean enableVideoIfAvailable;
|
||||
private androidx.window.WindowManager windowManager;
|
||||
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
|
||||
private ThrottledDebouncer requestNewSizesThrottle;
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(@NonNull Context newBase) {
|
||||
@@ -99,6 +116,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
super.attachBaseContext(newBase);
|
||||
}
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Log.i(TAG, "onCreate()");
|
||||
@@ -106,6 +124,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
boolean isLandscapeEnabled = getResources().getConfiguration().smallestScreenWidthDp >= 480;
|
||||
if (!isLandscapeEnabled) {
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
|
||||
}
|
||||
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
setContentView(R.layout.webrtc_call_activity);
|
||||
|
||||
@@ -114,12 +137,19 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
setVolumeControlStream(AudioManager.STREAM_VOICE_CALL);
|
||||
|
||||
initializeResources();
|
||||
initializeViewModel();
|
||||
initializeViewModel(isLandscapeEnabled);
|
||||
|
||||
processIntent(getIntent());
|
||||
|
||||
enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false);
|
||||
getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
|
||||
|
||||
windowManager = new androidx.window.WindowManager(this);
|
||||
windowLayoutInfoConsumer = new WindowLayoutInfoConsumer();
|
||||
|
||||
windowManager.registerLayoutChangeCallback(SignalExecutors.BOUNDED, windowLayoutInfoConsumer);
|
||||
|
||||
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -164,6 +194,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
if (!isInPipMode() || isFinishing()) {
|
||||
EventBus.getDefault().unregister(this);
|
||||
requestNewSizesThrottle.clear();
|
||||
}
|
||||
|
||||
if (!viewModel.isCallStarting()) {
|
||||
@@ -177,9 +208,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
windowManager.unregisterLayoutChangeCallback(windowLayoutInfoConsumer);
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
@@ -206,8 +239,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private boolean enterPipModeIfPossible() {
|
||||
if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) {
|
||||
PictureInPictureParams params = new PictureInPictureParams.Builder()
|
||||
.setAspectRatio(new Rational(9, 16))
|
||||
.build();
|
||||
.setAspectRatio(new Rational(9, 16))
|
||||
.build();
|
||||
enterPictureInPictureMode(params);
|
||||
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
|
||||
|
||||
@@ -246,53 +279,46 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
|
||||
}
|
||||
|
||||
private void initializeViewModel() {
|
||||
private void initializeViewModel(boolean isLandscapeEnabled) {
|
||||
deviceOrientationMonitor = new DeviceOrientationMonitor(this);
|
||||
getLifecycle().addObserver(deviceOrientationMonitor);
|
||||
|
||||
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
|
||||
|
||||
viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class);
|
||||
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
|
||||
viewModel.setIsInPipMode(isInPipMode());
|
||||
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
||||
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
|
||||
viewModel.getEvents().observe(this, this::handleViewModelEvent);
|
||||
viewModel.getCallTime().observe(this, this::handleCallTime);
|
||||
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(), viewModel.getOrientation(), (s, o) -> new Pair<>(s, o == PORTRAIT_BOTTOM_EDGE))
|
||||
.observe(this, p -> callScreen.updateCallParticipants(p.first(), p.second()));
|
||||
|
||||
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
|
||||
viewModel.getOrientationAndLandscapeEnabled(),
|
||||
(s, o) -> new CallParticipantsViewState(s, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
|
||||
.observe(this, p -> callScreen.updateCallParticipants(p));
|
||||
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
|
||||
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
|
||||
viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall());
|
||||
viewModel.getGroupMembersChanged().observe(this, unused -> updateGroupMembersForGroupCall());
|
||||
viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange);
|
||||
viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint);
|
||||
|
||||
callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
|
||||
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
|
||||
if (state != null) {
|
||||
if (state.needsNewRequestSizes()) {
|
||||
ApplicationDependencies.getSignalCallManager().updateRenderedResolutions();
|
||||
requestNewSizesThrottle.publish(() -> ApplicationDependencies.getSignalCallManager().updateRenderedResolutions());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getOrientation().observe(this, orientation -> {
|
||||
ApplicationDependencies.getSignalCallManager().orientationChanged(orientation.getDegrees());
|
||||
|
||||
switch (orientation) {
|
||||
case LANDSCAPE_LEFT_EDGE:
|
||||
callScreen.rotateControls(90);
|
||||
break;
|
||||
case LANDSCAPE_RIGHT_EDGE:
|
||||
callScreen.rotateControls(-90);
|
||||
break;
|
||||
case PORTRAIT_BOTTOM_EDGE:
|
||||
callScreen.rotateControls(0);
|
||||
}
|
||||
});
|
||||
viewModel.getOrientationAndLandscapeEnabled().observe(this, pair -> ApplicationDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees()));
|
||||
viewModel.getControlsRotation().observe(this, callScreen::rotateControls);
|
||||
}
|
||||
|
||||
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
|
||||
if (event instanceof WebRtcCallViewModel.Event.StartCall) {
|
||||
startCall(((WebRtcCallViewModel.Event.StartCall)event).isVideoCall());
|
||||
startCall(((WebRtcCallViewModel.Event.StartCall) event).isVideoCall());
|
||||
return;
|
||||
} else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) {
|
||||
SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords());
|
||||
@@ -404,7 +430,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
.request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
|
||||
.ifNecessary()
|
||||
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
|
||||
R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
|
||||
R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
|
||||
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
|
||||
.onAllGranted(() -> {
|
||||
callScreen.setRecipient(recipient);
|
||||
@@ -488,13 +514,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private void handleNoSuchUser(final @NonNull WebRtcViewModel event) {
|
||||
if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(R.string.RedPhone_number_not_registered)
|
||||
.setIcon(R.drawable.ic_warning)
|
||||
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
|
||||
.setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
|
||||
.show();
|
||||
.setTitle(R.string.RedPhone_number_not_registered)
|
||||
.setIcon(R.drawable.ic_warning)
|
||||
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
|
||||
.setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) {
|
||||
@@ -524,6 +550,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
ApplicationDependencies.getSignalCallManager().requestUpdateGroupMembers();
|
||||
}
|
||||
|
||||
public void handleGroupMemberCountChange(int count) {
|
||||
boolean canRing = count <= FeatureFlags.maxGroupCallRingSize() && FeatureFlags.groupCallRinging();
|
||||
callScreen.enableRingGroup(canRing);
|
||||
ApplicationDependencies.getSignalCallManager().setRingGroup(canRing);
|
||||
}
|
||||
|
||||
private void updateSpeakerHint(boolean showSpeakerHint) {
|
||||
if (showSpeakerHint) {
|
||||
callScreen.showSpeakerViewHint();
|
||||
@@ -586,20 +618,34 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
|
||||
switch (event.getState()) {
|
||||
case CALL_PRE_JOIN: handleCallPreJoin(event); break;
|
||||
case CALL_CONNECTED: handleCallConnected(event); break;
|
||||
case NETWORK_FAILURE: handleServerFailure(); break;
|
||||
case CALL_RINGING: handleCallRinging(); break;
|
||||
case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
|
||||
case CALL_ACCEPTED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
|
||||
case CALL_DECLINED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
|
||||
case CALL_ONGOING_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
|
||||
case CALL_NEEDS_PERMISSION: handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
|
||||
case NO_SUCH_USER: handleNoSuchUser(event); break;
|
||||
case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(); break;
|
||||
case CALL_OUTGOING: handleOutgoingCall(event); break;
|
||||
case CALL_BUSY: handleCallBusy(); break;
|
||||
case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break;
|
||||
case CALL_PRE_JOIN:
|
||||
handleCallPreJoin(event); break;
|
||||
case CALL_CONNECTED:
|
||||
handleCallConnected(event); break;
|
||||
case NETWORK_FAILURE:
|
||||
handleServerFailure(); break;
|
||||
case CALL_RINGING:
|
||||
handleCallRinging(); break;
|
||||
case CALL_DISCONNECTED:
|
||||
handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
|
||||
case CALL_ACCEPTED_ELSEWHERE:
|
||||
handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
|
||||
case CALL_DECLINED_ELSEWHERE:
|
||||
handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
|
||||
case CALL_ONGOING_ELSEWHERE:
|
||||
handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
|
||||
case CALL_NEEDS_PERMISSION:
|
||||
handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
|
||||
case NO_SUCH_USER:
|
||||
handleNoSuchUser(event); break;
|
||||
case RECIPIENT_UNAVAILABLE:
|
||||
handleRecipientUnavailable(); break;
|
||||
case CALL_OUTGOING:
|
||||
handleOutgoingCall(event); break;
|
||||
case CALL_BUSY:
|
||||
handleCallBusy(); break;
|
||||
case UNTRUSTED_IDENTITY:
|
||||
handleUntrustedIdentity(event); break;
|
||||
}
|
||||
|
||||
boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable;
|
||||
@@ -615,6 +661,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
|
||||
if (event.getGroupState().isNotIdle()) {
|
||||
callScreen.setStatusFromGroupCallState(event.getGroupState());
|
||||
callScreen.setRingGroup(event.shouldRingGroup());
|
||||
|
||||
if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
|
||||
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,5 +780,38 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
public void onLocalPictureInPictureClicked() {
|
||||
viewModel.onLocalPictureInPictureClicked();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) {
|
||||
if (ringingAllowed) {
|
||||
ApplicationDependencies.getSignalCallManager().setRingGroup(ringGroup);
|
||||
} else {
|
||||
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
|
||||
Toast.makeText(WebRtcCallActivity.this, R.string.WebRtcCallActivity__group_is_too_large_to_ring_the_participants, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {
|
||||
|
||||
@Override
|
||||
public void accept(WindowLayoutInfo windowLayoutInfo) {
|
||||
Log.d(TAG, "On WindowLayoutInfo accepted: " + windowLayoutInfo.toString());
|
||||
|
||||
Optional<DisplayFeature> feature = windowLayoutInfo.getDisplayFeatures().stream().filter(f -> f instanceof FoldingFeature).findFirst();
|
||||
viewModel.setIsLandscapeEnabled(feature.isPresent());
|
||||
setRequestedOrientation(feature.isPresent() ? ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED : ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
|
||||
if (feature.isPresent()) {
|
||||
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
|
||||
Rect bounds = foldingFeature.getBounds();
|
||||
if (foldingFeature.getState() == FoldingFeature.State.HALF_OPENED && bounds.top == bounds.bottom) {
|
||||
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top display mode");
|
||||
viewModel.setFoldableState(WebRtcControls.FoldableState.folded(bounds.top));
|
||||
} else {
|
||||
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in flat display mode");
|
||||
viewModel.setFoldableState(WebRtcControls.FoldableState.flat());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ public class AudioCodec {
|
||||
private final AudioRecord audioRecord;
|
||||
|
||||
private boolean running = true;
|
||||
private boolean failed = false;
|
||||
private boolean finished = false;
|
||||
|
||||
public AudioCodec() throws IOException {
|
||||
@@ -76,10 +77,25 @@ public class AudioCodec {
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
} finally {
|
||||
mediaCodec.stop();
|
||||
audioRecord.stop();
|
||||
|
||||
mediaCodec.release();
|
||||
try {
|
||||
mediaCodec.stop();
|
||||
} catch (IllegalStateException ise) {
|
||||
Log.w(TAG, "mediaCodec stop failed.", ise);
|
||||
}
|
||||
|
||||
try {
|
||||
audioRecord.stop();
|
||||
} catch (IllegalStateException ise) {
|
||||
Log.w(TAG, "audioRecord stop failed.", ise);
|
||||
}
|
||||
|
||||
try {
|
||||
mediaCodec.release();
|
||||
} catch (IllegalStateException ise) {
|
||||
Log.w(TAG, "mediaCodec release failed. Probably already released.", ise);
|
||||
}
|
||||
|
||||
audioRecord.release();
|
||||
|
||||
StreamUtil.close(outputStream);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Represents an Avatar which the user can choose, edit, and render into a bitmap via the renderer.
|
||||
*/
|
||||
sealed class Avatar(
|
||||
open val databaseId: DatabaseId
|
||||
) {
|
||||
data class Resource(
|
||||
val resourceId: Int,
|
||||
val color: Avatars.ColorPair
|
||||
) : Avatar(DatabaseId.DoNotPersist) {
|
||||
override fun isSameAs(other: Avatar): Boolean {
|
||||
return other is Resource && other.resourceId == resourceId
|
||||
}
|
||||
}
|
||||
|
||||
data class Text(
|
||||
val text: String,
|
||||
val color: Avatars.ColorPair,
|
||||
override val databaseId: DatabaseId,
|
||||
) : Avatar(databaseId) {
|
||||
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||
return copy(databaseId = databaseId)
|
||||
}
|
||||
|
||||
override fun isSameAs(other: Avatar): Boolean {
|
||||
return other is Text && other.databaseId == databaseId
|
||||
}
|
||||
}
|
||||
|
||||
data class Vector(
|
||||
val key: String,
|
||||
val color: Avatars.ColorPair,
|
||||
override val databaseId: DatabaseId,
|
||||
) : Avatar(databaseId) {
|
||||
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||
return copy(databaseId = databaseId)
|
||||
}
|
||||
|
||||
override fun isSameAs(other: Avatar): Boolean {
|
||||
return other is Vector && other.key == key
|
||||
}
|
||||
}
|
||||
|
||||
data class Photo(
|
||||
val uri: Uri,
|
||||
val size: Long,
|
||||
override val databaseId: DatabaseId
|
||||
) : Avatar(databaseId) {
|
||||
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||
return copy(databaseId = databaseId)
|
||||
}
|
||||
|
||||
override fun isSameAs(other: Avatar): Boolean {
|
||||
return other is Photo && databaseId == other.databaseId
|
||||
}
|
||||
}
|
||||
|
||||
open fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
abstract fun isSameAs(other: Avatar): Boolean
|
||||
|
||||
companion object {
|
||||
fun getDefaultForSelf(): Resource = Resource(R.drawable.ic_profile_outline_40, Avatars.colors.random())
|
||||
fun getDefaultForGroup(): Resource = Resource(R.drawable.ic_group_outline_40, Avatars.colors.random())
|
||||
}
|
||||
|
||||
sealed class DatabaseId {
|
||||
object DoNotPersist : DatabaseId()
|
||||
object NotSet : DatabaseId()
|
||||
data class Saved(val id: Long) : DatabaseId()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.os.Bundle
|
||||
import java.lang.IllegalStateException
|
||||
|
||||
/**
|
||||
* Utility class which encapsulates reading and writing Avatar objects to and from Bundles.
|
||||
*/
|
||||
object AvatarBundler {
|
||||
|
||||
private const val TEXT = "org.thoughtcrime.securesms.avatar.TEXT"
|
||||
private const val COLOR = "org.thoughtcrime.securesms.avatar.COLOR"
|
||||
private const val URI = "org.thoughtcrime.securesms.avatar.URI"
|
||||
private const val KEY = "org.thoughtcrime.securesms.avatar.KEY"
|
||||
private const val DATABASE_ID = "org.thoughtcrime.securesms.avatar.DATABASE_ID"
|
||||
private const val SIZE = "org.thoughtcrime.securesms.avatar.SIZE"
|
||||
|
||||
fun bundleText(text: Avatar.Text): Bundle = Bundle().apply {
|
||||
putString(TEXT, text.text)
|
||||
putString(COLOR, text.color.code)
|
||||
putDatabaseId(DATABASE_ID, text.databaseId)
|
||||
}
|
||||
|
||||
fun extractText(bundle: Bundle): Avatar.Text = Avatar.Text(
|
||||
text = requireNotNull(bundle.getString(TEXT)),
|
||||
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
|
||||
databaseId = bundle.getDatabaseId()
|
||||
)
|
||||
|
||||
fun bundlePhoto(photo: Avatar.Photo): Bundle = Bundle().apply {
|
||||
putParcelable(URI, photo.uri)
|
||||
putLong(SIZE, photo.size)
|
||||
putDatabaseId(DATABASE_ID, photo.databaseId)
|
||||
}
|
||||
|
||||
fun extractPhoto(bundle: Bundle): Avatar.Photo = Avatar.Photo(
|
||||
uri = requireNotNull(bundle.getParcelable(URI)),
|
||||
size = bundle.getLong(SIZE),
|
||||
databaseId = bundle.getDatabaseId()
|
||||
)
|
||||
|
||||
fun bundleVector(vector: Avatar.Vector): Bundle = Bundle().apply {
|
||||
putString(KEY, vector.key)
|
||||
putString(COLOR, vector.color.code)
|
||||
putDatabaseId(DATABASE_ID, vector.databaseId)
|
||||
}
|
||||
|
||||
fun extractVector(bundle: Bundle): Avatar.Vector = Avatar.Vector(
|
||||
key = requireNotNull(bundle.getString(KEY)),
|
||||
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
|
||||
databaseId = bundle.getDatabaseId()
|
||||
)
|
||||
|
||||
private fun Bundle.getDatabaseId(): Avatar.DatabaseId {
|
||||
val id = getLong(DATABASE_ID, -1L)
|
||||
|
||||
return if (id == -1L) {
|
||||
Avatar.DatabaseId.NotSet
|
||||
} else {
|
||||
Avatar.DatabaseId.Saved(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Bundle.putDatabaseId(key: String, databaseId: Avatar.DatabaseId) {
|
||||
if (databaseId is Avatar.DatabaseId.Saved) {
|
||||
putLong(key, databaseId.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
typealias OnAvatarColorClickListener = (Avatars.ColorPair) -> Unit
|
||||
|
||||
/**
|
||||
* Selectable color item for choosing colors when editing a Text or Vector avatar.
|
||||
*/
|
||||
data class AvatarColorItem(
|
||||
val colors: Avatars.ColorPair,
|
||||
val selected: Boolean
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun registerViewHolder(adapter: MappingAdapter, onAvatarColorClickListener: OnAvatarColorClickListener) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(val colorItem: AvatarColorItem) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = newItem.colorItem.colors == colorItem.colors
|
||||
override fun areContentsTheSame(newItem: Model): Boolean = newItem.colorItem == colorItem
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View, private val onAvatarColorClickListener: OnAvatarColorClickListener) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val imageView: ImageView = findViewById(R.id.avatar_color_item)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener { onAvatarColorClickListener(model.colorItem.colors) }
|
||||
imageView.background.colorFilter = SimpleColorFilter(model.colorItem.colors.backgroundColor)
|
||||
imageView.isSelected = model.colorItem.selected
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.storage.FileStorage
|
||||
import java.io.InputStream
|
||||
|
||||
object AvatarPickerStorage {
|
||||
|
||||
private const val DIRECTORY = "avatar_picker"
|
||||
private const val FILENAME_BASE = "avatar"
|
||||
|
||||
@JvmStatic
|
||||
fun read(context: Context, fileName: String) = FileStorage.read(context, DIRECTORY, fileName)
|
||||
|
||||
fun save(context: Context, media: Media): Uri {
|
||||
val fileName = FileStorage.save(context, PartAuthority.getAttachmentStream(context, media.uri), DIRECTORY, FILENAME_BASE, MediaUtil.getExtension(context, media.uri) ?: "")
|
||||
|
||||
return PartAuthority.getAvatarPickerUri(fileName)
|
||||
}
|
||||
|
||||
fun save(context: Context, inputStream: InputStream): Uri {
|
||||
val fileName = FileStorage.save(context, inputStream, DIRECTORY, FILENAME_BASE, MimeTypeMap.getSingleton().getExtensionFromMimeType(MediaUtil.IMAGE_JPEG) ?: "")
|
||||
|
||||
return PartAuthority.getAvatarPickerUri(fileName)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun cleanOrphans(context: Context) {
|
||||
val avatarFiles = FileStorage.getAllFiles(context, DIRECTORY, FILENAME_BASE)
|
||||
val database = DatabaseFactory.getAvatarPickerDatabase(context)
|
||||
val photoAvatars = database
|
||||
.getAllAvatars()
|
||||
.filterIsInstance<Avatar.Photo>()
|
||||
|
||||
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
|
||||
val onDiskFileNames = avatarFiles.map { it.name }
|
||||
|
||||
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
|
||||
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
|
||||
|
||||
avatarFiles
|
||||
.filter { onDiskButNotInDatabase.contains(it.name) }
|
||||
.forEach { it.delete() }
|
||||
|
||||
photoAvatars
|
||||
.filter { inDatabaseButNotOnDisk.contains(PartAuthority.getAvatarPickerFilename(it.uri)) }
|
||||
.forEach { database.deleteAvatar(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import javax.annotation.meta.Exhaustive
|
||||
|
||||
/**
|
||||
* Renders Avatar objects into Media objects. This can involve creating a Bitmap, depending on the
|
||||
* type of Avatar passed to `renderAvatar`
|
||||
*/
|
||||
object AvatarRenderer {
|
||||
|
||||
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
|
||||
|
||||
fun getTypeface(context: Context): Typeface {
|
||||
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
|
||||
}
|
||||
|
||||
fun renderAvatar(context: Context, avatar: Avatar, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||
@Exhaustive
|
||||
when (avatar) {
|
||||
is Avatar.Resource -> renderResource(context, avatar, onAvatarRendered, onRenderFailed)
|
||||
is Avatar.Vector -> renderVector(context, avatar, onAvatarRendered, onRenderFailed)
|
||||
is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered)
|
||||
is Avatar.Text -> renderText(context, avatar, onAvatarRendered, onRenderFailed)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createTextDrawable(
|
||||
context: Context,
|
||||
avatar: Avatar.Text,
|
||||
inverted: Boolean = false,
|
||||
size: Int = DIMENSIONS,
|
||||
): Drawable {
|
||||
return TextAvatarDrawable(context, avatar, inverted, size)
|
||||
}
|
||||
|
||||
private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
|
||||
val drawableResourceId = Avatars.getDrawableResource(avatar.key) ?: return@renderInBackground Result.failure(Exception("Drawable resource for key ${avatar.key} does not exist."))
|
||||
val vector: Drawable = requireNotNull(AppCompatResources.getDrawable(context, drawableResourceId))
|
||||
vector.setBounds(0, 0, DIMENSIONS, DIMENSIONS)
|
||||
|
||||
canvas.drawColor(avatar.color.backgroundColor)
|
||||
vector.draw(canvas)
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderText(context: Context, avatar: Avatar.Text, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
|
||||
val textDrawable = createTextDrawable(context, avatar)
|
||||
|
||||
canvas.drawColor(avatar.color.backgroundColor)
|
||||
textDrawable.draw(canvas)
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val blob = BlobProvider.getInstance()
|
||||
.forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size)
|
||||
.createForSingleSessionOnDisk(context)
|
||||
|
||||
onAvatarRendered(createMedia(blob, avatar.size))
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderResource(context: Context, avatar: Avatar.Resource, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
|
||||
val resource: Drawable = requireNotNull(AppCompatResources.getDrawable(context, avatar.resourceId))
|
||||
resource.colorFilter = SimpleColorFilter(avatar.color.foregroundColor)
|
||||
|
||||
val padding = (DIMENSIONS * 0.2).toInt()
|
||||
resource.setBounds(0 + padding, 0 + padding, DIMENSIONS - padding, DIMENSIONS - padding)
|
||||
|
||||
canvas.drawColor(avatar.color.backgroundColor)
|
||||
resource.draw(canvas)
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderInBackground(context: Context, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit, drawAvatar: (Canvas) -> Result<Unit>) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(canvasBitmap)
|
||||
|
||||
val drawResult = drawAvatar(canvas)
|
||||
if (drawResult.isFailure) {
|
||||
canvasBitmap.recycle()
|
||||
onRenderFailed(drawResult.exceptionOrNull())
|
||||
}
|
||||
|
||||
val outStream = ByteArrayOutputStream()
|
||||
val compressed = canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream)
|
||||
canvasBitmap.recycle()
|
||||
|
||||
if (!compressed) {
|
||||
onRenderFailed(IOException("Failed to compress bitmap"))
|
||||
return@execute
|
||||
}
|
||||
|
||||
val bytes = outStream.toByteArray()
|
||||
val inStream = ByteArrayInputStream(bytes)
|
||||
val uri = BlobProvider.getInstance().forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
|
||||
|
||||
onAvatarRendered(createMedia(uri, bytes.size.toLong()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMedia(uri: Uri, size: Long): Media {
|
||||
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent())
|
||||
}
|
||||
}
|
||||
153
app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt
Normal file
153
app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt
Normal file
@@ -0,0 +1,153 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Paint
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.Px
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
object Avatars {
|
||||
|
||||
/**
|
||||
* Enum class mirroring AvatarColors codes but utilizing foreground colors for text or icon tinting.
|
||||
*/
|
||||
enum class ForegroundColor(private val code: String, @ColorInt val colorInt: Int) {
|
||||
A100("A100", 0xFF3838F5.toInt()),
|
||||
A110("A110", 0xFF1251D3.toInt()),
|
||||
A120("A120", 0xFF086DA0.toInt()),
|
||||
A130("A130", 0xFF067906.toInt()),
|
||||
A140("A140", 0xFF661AFF.toInt()),
|
||||
A150("A150", 0xFF9F00F0.toInt()),
|
||||
A160("A160", 0xFFB8057C.toInt()),
|
||||
A170("A170", 0xFFBE0404.toInt()),
|
||||
A180("A180", 0xFF836B01.toInt()),
|
||||
A190("A190", 0xFF7D6F40.toInt()),
|
||||
A200("A200", 0xFF4F4F6D.toInt()),
|
||||
A210("A210", 0xFF5C5C5C.toInt());
|
||||
|
||||
fun deserialize(code: String): ForegroundColor {
|
||||
return values().find { it.code == code } ?: throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
fun serialize(): String = code
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping which associates color codes to ColorPair objects containing background and foreground colors.
|
||||
*/
|
||||
val colorMap: Map<String, ColorPair> = ForegroundColor.values().map {
|
||||
ColorPair(AvatarColor.deserialize(it.serialize()), it)
|
||||
}.associateBy {
|
||||
it.code
|
||||
}
|
||||
|
||||
val colors: List<ColorPair> = colorMap.values.toList()
|
||||
|
||||
val defaultAvatarsForSelf = linkedMapOf(
|
||||
"avatar_abstract_01" to DefaultAvatar(R.drawable.ic_avatar_abstract_01, "A130"),
|
||||
"avatar_abstract_02" to DefaultAvatar(R.drawable.ic_avatar_abstract_02, "A120"),
|
||||
"avatar_abstract_03" to DefaultAvatar(R.drawable.ic_avatar_abstract_03, "A170"),
|
||||
"avatar_cat" to DefaultAvatar(R.drawable.ic_avatar_cat, "A190"),
|
||||
"avatar_dog" to DefaultAvatar(R.drawable.ic_avatar_dog, "A140"),
|
||||
"avatar_fox" to DefaultAvatar(R.drawable.ic_avatar_fox, "A190"),
|
||||
"avatar_tucan" to DefaultAvatar(R.drawable.ic_avatar_tucan, "A120"),
|
||||
"avatar_sloth" to DefaultAvatar(R.drawable.ic_avatar_sloth, "A160"),
|
||||
"avatar_dinosaur" to DefaultAvatar(R.drawable.ic_avatar_dinosour, "A130"),
|
||||
"avatar_pig" to DefaultAvatar(R.drawable.ic_avatar_pig, "A180"),
|
||||
"avatar_incognito" to DefaultAvatar(R.drawable.ic_avatar_incognito, "A220"),
|
||||
"avatar_ghost" to DefaultAvatar(R.drawable.ic_avatar_ghost, "A100")
|
||||
)
|
||||
|
||||
val defaultAvatarsForGroup = linkedMapOf(
|
||||
"avatar_heart" to DefaultAvatar(R.drawable.ic_avatar_heart, "A180"),
|
||||
"avatar_house" to DefaultAvatar(R.drawable.ic_avatar_house, "A120"),
|
||||
"avatar_melon" to DefaultAvatar(R.drawable.ic_avatar_melon, "A110"),
|
||||
"avatar_drink" to DefaultAvatar(R.drawable.ic_avatar_drink, "A170"),
|
||||
"avatar_celebration" to DefaultAvatar(R.drawable.ic_avatar_celebration, "A100"),
|
||||
"avatar_balloon" to DefaultAvatar(R.drawable.ic_avatar_balloon, "A220"),
|
||||
"avatar_book" to DefaultAvatar(R.drawable.ic_avatar_book, "A100"),
|
||||
"avatar_briefcase" to DefaultAvatar(R.drawable.ic_avatar_briefcase, "A180"),
|
||||
"avatar_sunset" to DefaultAvatar(R.drawable.ic_avatar_sunset, "A120"),
|
||||
"avatar_surfboard" to DefaultAvatar(R.drawable.ic_avatar_surfboard, "A110"),
|
||||
"avatar_soccerball" to DefaultAvatar(R.drawable.ic_avatar_soccerball, "A130"),
|
||||
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220"),
|
||||
)
|
||||
|
||||
@DrawableRes
|
||||
fun getDrawableResource(key: String): Int? {
|
||||
val defaultAvatar = defaultAvatarsForSelf.getOrDefault(key, defaultAvatarsForGroup[key])
|
||||
|
||||
return defaultAvatar?.vectorDrawableId
|
||||
}
|
||||
|
||||
private fun textPaint(context: Context) = Paint().apply {
|
||||
isAntiAlias = true
|
||||
typeface = AvatarRenderer.getTypeface(context)
|
||||
textSize = 1f
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the text size for a give string using a maximum desired width and a maximum desired font size.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getTextSizeForLength(context: Context, text: String, @Px maxWidth: Float, @Px maxSize: Float): Float {
|
||||
val paint = textPaint(context)
|
||||
return branchSizes(0f, maxWidth / 2, maxWidth, maxSize, paint, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses binary search to determine optimal font size to within 1% given the input parameters.
|
||||
*/
|
||||
private fun branchSizes(@Px lastFontSize: Float, @Px fontSize: Float, @Px target: Float, @Px maxFontSize: Float, paint: Paint, text: String): Float {
|
||||
paint.textSize = fontSize
|
||||
val textWidth = paint.measureText(text)
|
||||
val delta = abs(lastFontSize - fontSize) / 2f
|
||||
val isWithinThreshold = abs(1f - (textWidth / target)) <= 0.01f
|
||||
|
||||
if (textWidth == 0f) {
|
||||
return maxFontSize
|
||||
}
|
||||
|
||||
if (delta == 0f) {
|
||||
return min(maxFontSize, fontSize)
|
||||
}
|
||||
|
||||
return when {
|
||||
fontSize >= maxFontSize -> {
|
||||
maxFontSize
|
||||
}
|
||||
isWithinThreshold -> {
|
||||
fontSize
|
||||
}
|
||||
textWidth > target -> {
|
||||
branchSizes(fontSize, fontSize - delta, target, maxFontSize, paint, text)
|
||||
}
|
||||
else -> {
|
||||
branchSizes(fontSize, fontSize + delta, target, maxFontSize, paint, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getForegroundColor(avatarColor: AvatarColor): ForegroundColor {
|
||||
return ForegroundColor.values().firstOrNull { it.serialize() == avatarColor.serialize() } ?: ForegroundColor.A210
|
||||
}
|
||||
|
||||
data class DefaultAvatar(
|
||||
@DrawableRes val vectorDrawableId: Int,
|
||||
val colorCode: String
|
||||
)
|
||||
|
||||
data class ColorPair(
|
||||
val backgroundAvatarColor: AvatarColor,
|
||||
val foregroundAvatarColor: ForegroundColor
|
||||
) {
|
||||
@ColorInt val backgroundColor: Int = backgroundAvatarColor.colorInt()
|
||||
@ColorInt val foregroundColor: Int = foregroundAvatarColor.colorInt
|
||||
val code: String = backgroundAvatarColor.serialize()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
|
||||
/**
|
||||
* Uses EmojiTextView to properly render a Text Avatar with emoji in it.
|
||||
*/
|
||||
class TextAvatarDrawable(
|
||||
context: Context,
|
||||
avatar: Avatar.Text,
|
||||
inverted: Boolean = false,
|
||||
private val size: Int = AvatarRenderer.DIMENSIONS,
|
||||
) : Drawable() {
|
||||
|
||||
private val layout: FrameLayout = FrameLayout(context)
|
||||
private val textView: EmojiTextView = EmojiTextView(context)
|
||||
|
||||
init {
|
||||
textView.typeface = AvatarRenderer.getTypeface(context)
|
||||
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f))
|
||||
textView.text = avatar.text
|
||||
textView.gravity = Gravity.CENTER
|
||||
textView.setTextColor(if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor)
|
||||
textView.setForceCustomEmoji(true)
|
||||
|
||||
layout.addView(textView)
|
||||
|
||||
textView.updateLayoutParams {
|
||||
width = size
|
||||
height = size
|
||||
}
|
||||
|
||||
layout.measure(size, size)
|
||||
layout.layout(0, 0, size, size)
|
||||
}
|
||||
|
||||
override fun getIntrinsicHeight(): Int = size
|
||||
|
||||
override fun getIntrinsicWidth(): Int = size
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
layout.draw(canvas)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) = Unit
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||
|
||||
override fun getOpacity(): Int = PixelFormat.OPAQUE
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.thoughtcrime.securesms.avatar.photo
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.Navigation
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||
|
||||
class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), ImageEditorFragment.Controller {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
|
||||
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
|
||||
val imageEditorFragment = ImageEditorFragment.newInstanceForAvatarEdit(photo.uri)
|
||||
|
||||
childFragmentManager.commit {
|
||||
add(R.id.fragment_container, imageEditorFragment, IMAGE_EDITOR)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEventsNeeded(needed: Boolean) {
|
||||
}
|
||||
|
||||
override fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) {
|
||||
}
|
||||
|
||||
override fun onDoneEditing() {
|
||||
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
|
||||
val applicationContext = requireContext().applicationContext
|
||||
val imageEditorFragment: ImageEditorFragment = childFragmentManager.findFragmentByTag(IMAGE_EDITOR) as ImageEditorFragment
|
||||
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val editedImageUri = imageEditorFragment.renderToSingleUseBlob()
|
||||
val size = BlobProvider.getFileSize(editedImageUri) ?: 0
|
||||
val inputStream = BlobProvider.getInstance().getStream(applicationContext, editedImageUri)
|
||||
val onDiskUri = AvatarPickerStorage.save(applicationContext, inputStream)
|
||||
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
|
||||
val database = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
|
||||
val newPhoto = photo.copy(uri = onDiskUri, size = size)
|
||||
|
||||
database.update(newPhoto)
|
||||
BlobProvider.getInstance().delete(requireContext(), photo.uri)
|
||||
|
||||
ThreadUtil.runOnMain {
|
||||
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
|
||||
Navigation.findNavController(requireView()).popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelEditing() {
|
||||
Navigation.findNavController(requireView()).popBackStack()
|
||||
}
|
||||
|
||||
override fun onMainImageLoaded() {
|
||||
}
|
||||
|
||||
override fun onMainImageFailedToLoad() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY_EDIT = "org.thoughtcrime.securesms.avatar.photo.EDIT"
|
||||
|
||||
private const val IMAGE_EDITOR = "image_editor"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment
|
||||
import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
|
||||
*/
|
||||
class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR"
|
||||
const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA"
|
||||
const val SELECT_AVATAR_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR"
|
||||
|
||||
private const val REQUEST_CODE_SELECT_IMAGE = 1
|
||||
}
|
||||
|
||||
private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory)
|
||||
|
||||
private lateinit var recycler: RecyclerView
|
||||
|
||||
private fun createFactory(): AvatarPickerViewModel.Factory {
|
||||
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = ParcelableGroupId.get(args.groupId)
|
||||
|
||||
return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), groupId, args.isNewGroup, args.groupAvatarMedia)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.avatar_picker_toolbar)
|
||||
val cameraButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_camera)
|
||||
val photoButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_photo)
|
||||
val textButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_text)
|
||||
val saveButton: View = view.findViewById(R.id.avatar_picker_save)
|
||||
val clearButton: View = view.findViewById(R.id.avatar_picker_clear)
|
||||
|
||||
recycler = view.findViewById(R.id.avatar_picker_recycler)
|
||||
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
|
||||
|
||||
val adapter = MappingAdapter()
|
||||
AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick)
|
||||
|
||||
recycler.adapter = adapter
|
||||
|
||||
val avatarViewHolder = AvatarPickerItem.ViewHolder(view)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
if (state.currentAvatar != null) {
|
||||
avatarViewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
|
||||
}
|
||||
|
||||
clearButton.visible = state.canClear
|
||||
|
||||
val wasEnabled = saveButton.isEnabled
|
||||
saveButton.isEnabled = state.canSave
|
||||
if (wasEnabled != state.canSave) {
|
||||
val alpha = if (state.canSave) 1f else 0.5f
|
||||
saveButton.animate().cancel()
|
||||
saveButton.animate().alpha(alpha)
|
||||
}
|
||||
|
||||
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
|
||||
val selectedPosition = items.indexOfFirst { it.isSelected }
|
||||
|
||||
adapter.submitList(items) {
|
||||
if (selectedPosition > -1)
|
||||
recycler.smoothScrollToPosition(selectedPosition)
|
||||
}
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
|
||||
cameraButton.setOnIconClickedListener { openCameraCapture() }
|
||||
photoButton.setOnIconClickedListener { openGallery() }
|
||||
textButton.setOnIconClickedListener { openTextEditor(null) }
|
||||
saveButton.setOnClickListener { v ->
|
||||
viewModel.save(
|
||||
{
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_SELECT_AVATAR,
|
||||
Bundle().apply {
|
||||
putParcelable(SELECT_AVATAR_MEDIA, it)
|
||||
}
|
||||
)
|
||||
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
|
||||
},
|
||||
{
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_SELECT_AVATAR,
|
||||
Bundle().apply {
|
||||
putBoolean(SELECT_AVATAR_CLEAR, true)
|
||||
}
|
||||
)
|
||||
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
|
||||
}
|
||||
)
|
||||
}
|
||||
clearButton.setOnClickListener { viewModel.clear() }
|
||||
|
||||
setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle ->
|
||||
val text = AvatarBundler.extractText(bundle)
|
||||
viewModel.onAvatarEditCompleted(text)
|
||||
}
|
||||
|
||||
setFragmentResultListener(VectorAvatarCreationFragment.REQUEST_KEY_VECTOR) { _, bundle ->
|
||||
val vector = AvatarBundler.extractVector(bundle)
|
||||
viewModel.onAvatarEditCompleted(vector)
|
||||
}
|
||||
|
||||
setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, bundle ->
|
||||
val photo = AvatarBundler.extractPhoto(bundle)
|
||||
viewModel.onAvatarEditCompleted(photo)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
|
||||
val media: Media = requireNotNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
|
||||
viewModel.onAvatarPhotoSelectionCompleted(media)
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAvatarClick(avatar: Avatar, isSelected: Boolean) {
|
||||
if (isSelected) {
|
||||
openEditor(avatar)
|
||||
} else {
|
||||
viewModel.onAvatarSelectedFromGrid(avatar)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAvatarLongClick(anchorView: View, avatar: Avatar): Boolean {
|
||||
val menuRes = when (avatar) {
|
||||
is Avatar.Photo -> R.menu.avatar_picker_context
|
||||
is Avatar.Text -> R.menu.avatar_picker_context
|
||||
is Avatar.Vector -> return true
|
||||
is Avatar.Resource -> return true
|
||||
}
|
||||
|
||||
val popup = PopupMenu(context, anchorView, Gravity.TOP)
|
||||
popup.menuInflater.inflate(menuRes, popup.menu)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_delete -> viewModel.delete(avatar)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun openEditor(avatar: Avatar) {
|
||||
when (avatar) {
|
||||
is Avatar.Photo -> openPhotoEditor(avatar)
|
||||
is Avatar.Resource -> throw UnsupportedOperationException()
|
||||
is Avatar.Text -> openTextEditor(avatar)
|
||||
is Avatar.Vector -> openVectorEditor(avatar)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPhotoEditor(photo: Avatar.Photo) {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
|
||||
}
|
||||
|
||||
private fun openVectorEditor(vector: Avatar.Vector) {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
|
||||
}
|
||||
|
||||
private fun openTextEditor(text: Avatar.Text?) {
|
||||
val bundle = if (text != null) AvatarBundler.bundleText(text) else null
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
|
||||
}
|
||||
|
||||
private fun openCameraCapture() {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.CAMERA)
|
||||
.ifNecessary()
|
||||
.onAllGranted {
|
||||
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
|
||||
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
|
||||
}
|
||||
.onAnyDenied {
|
||||
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
.execute()
|
||||
}
|
||||
|
||||
private fun openGallery() {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
.onAllGranted {
|
||||
val intent = AvatarSelectionActivity.getIntentForGallery(requireContext())
|
||||
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
|
||||
}
|
||||
.onAnyDenied {
|
||||
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.view.setPadding
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit
|
||||
typealias OnAvatarLongClickListener = (View, Avatar) -> Boolean
|
||||
|
||||
object AvatarPickerItem {
|
||||
|
||||
private val SELECTION_CHANGED = Any()
|
||||
|
||||
fun register(adapter: MappingAdapter, onAvatarClickListener: OnAvatarClickListener, onAvatarLongClickListener: OnAvatarLongClickListener) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item))
|
||||
}
|
||||
|
||||
class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = avatar.isSameAs(newItem.avatar)
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean = avatar == newItem.avatar && isSelected == newItem.isSelected
|
||||
|
||||
override fun getChangePayload(newItem: Model): Any? {
|
||||
return if (newItem.avatar == avatar && isSelected != newItem.isSelected) {
|
||||
SELECTION_CHANGED
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(
|
||||
itemView: View,
|
||||
private val onAvatarClickListener: OnAvatarClickListener? = null,
|
||||
private val onAvatarLongClickListener: OnAvatarLongClickListener? = null
|
||||
) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val imageView: ImageView = itemView.findViewById(R.id.avatar_picker_item_image)
|
||||
private val textView: TextView = itemView.findViewById(R.id.avatar_picker_item_text)
|
||||
private val selectedFader: View? = itemView.findViewById(R.id.avatar_picker_item_fader)
|
||||
private val selectedOverlay: View? = itemView.findViewById(R.id.avatar_picker_item_selection_overlay)
|
||||
|
||||
init {
|
||||
textView.typeface = AvatarRenderer.getTypeface(context)
|
||||
textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
updateFontSize(textView.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFontSize(text: String) {
|
||||
val textSize = Avatars.getTextSizeForLength(context, text, textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)
|
||||
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||
|
||||
if (textView !is EditText) {
|
||||
textView.text = text
|
||||
}
|
||||
}
|
||||
|
||||
override fun bind(model: Model) {
|
||||
val alpha = if (model.isSelected) 1f else 0f
|
||||
val scale = if (model.isSelected) 0.9f else 1f
|
||||
|
||||
imageView.animate().cancel()
|
||||
textView.animate().cancel()
|
||||
selectedOverlay?.animate()?.cancel()
|
||||
selectedFader?.animate()?.cancel()
|
||||
|
||||
itemView.setOnLongClickListener {
|
||||
onAvatarLongClickListener?.invoke(itemView, model.avatar) ?: false
|
||||
}
|
||||
|
||||
itemView.setOnClickListener { onAvatarClickListener?.invoke(model.avatar, model.isSelected) }
|
||||
|
||||
if (payload.isNotEmpty() && payload.contains(SELECTION_CHANGED)) {
|
||||
imageView.animate().scaleX(scale).scaleY(scale)
|
||||
textView.animate().scaleX(scale).scaleY(scale)
|
||||
selectedOverlay?.animate()?.alpha(alpha)
|
||||
selectedFader?.animate()?.alpha(alpha)
|
||||
return
|
||||
}
|
||||
|
||||
imageView.scaleX = scale
|
||||
imageView.scaleY = scale
|
||||
textView.scaleX = scale
|
||||
textView.scaleY = scale
|
||||
selectedFader?.alpha = alpha
|
||||
selectedOverlay?.alpha = alpha
|
||||
|
||||
imageView.clearColorFilter()
|
||||
imageView.setPadding(0)
|
||||
|
||||
when (model.avatar) {
|
||||
is Avatar.Text -> {
|
||||
textView.visible = true
|
||||
|
||||
updateFontSize(model.avatar.text)
|
||||
if (textView.text.toString() != model.avatar.text) {
|
||||
textView.text = model.avatar.text
|
||||
}
|
||||
|
||||
imageView.setImageDrawable(null)
|
||||
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
|
||||
textView.setTextColor(model.avatar.color.foregroundColor)
|
||||
}
|
||||
is Avatar.Vector -> {
|
||||
textView.visible = false
|
||||
|
||||
val drawableId = Avatars.getDrawableResource(model.avatar.key)
|
||||
if (drawableId == null) {
|
||||
imageView.setImageDrawable(null)
|
||||
} else {
|
||||
imageView.setImageDrawable(AppCompatResources.getDrawable(context, drawableId))
|
||||
}
|
||||
|
||||
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
|
||||
}
|
||||
is Avatar.Photo -> {
|
||||
textView.visible = false
|
||||
GlideApp.with(imageView).load(DecryptableStreamUriLoader.DecryptableUri(model.avatar.uri)).into(imageView)
|
||||
}
|
||||
is Avatar.Resource -> {
|
||||
imageView.setPadding((imageView.width * 0.2).toInt())
|
||||
textView.visible = false
|
||||
GlideApp.with(imageView).clear(imageView)
|
||||
imageView.setImageResource(model.avatar.resourceId)
|
||||
imageView.colorFilter = SimpleColorFilter(model.avatar.color.foregroundColor)
|
||||
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
|
||||
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.NameUtil
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails
|
||||
import java.io.IOException
|
||||
|
||||
private val TAG = Log.tag(AvatarPickerRepository::class.java)
|
||||
|
||||
class AvatarPickerRepository(context: Context) {
|
||||
|
||||
private val applicationContext = context.applicationContext
|
||||
|
||||
fun getAvatarForSelf(): Single<Avatar> = Single.fromCallable {
|
||||
val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(applicationContext)
|
||||
if (details != null) {
|
||||
try {
|
||||
val bytes = StreamUtil.readFully(details.stream)
|
||||
Avatar.Photo(
|
||||
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
|
||||
details.length,
|
||||
Avatar.DatabaseId.DoNotPersist
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to read avatar!")
|
||||
getDefaultAvatarForSelf()
|
||||
}
|
||||
} else {
|
||||
getDefaultAvatarForSelf()
|
||||
}
|
||||
}
|
||||
|
||||
fun getAvatarForGroup(groupId: GroupId): Single<Avatar> = Single.fromCallable {
|
||||
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
|
||||
|
||||
if (AvatarHelper.hasAvatar(applicationContext, recipient.id)) {
|
||||
try {
|
||||
val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id)
|
||||
Avatar.Photo(
|
||||
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
|
||||
AvatarHelper.getAvatarLength(applicationContext, recipient.id),
|
||||
Avatar.DatabaseId.DoNotPersist
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to read group avatar!")
|
||||
getDefaultAvatarForGroup(recipient.avatarColor)
|
||||
}
|
||||
} else {
|
||||
getDefaultAvatarForGroup(recipient.avatarColor)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPersistedAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
|
||||
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForSelf()
|
||||
}
|
||||
|
||||
fun getPersistedAvatarsForGroup(groupId: GroupId): Single<List<Avatar>> = Single.fromCallable {
|
||||
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForGroup(groupId)
|
||||
}
|
||||
|
||||
fun getDefaultAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
|
||||
Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry ->
|
||||
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultAvatarsForGroup(): Single<List<Avatar>> = Single.fromCallable {
|
||||
Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry ->
|
||||
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeMediaToMultiSessionStorage(media: Media, onMediaWrittenToMultiSessionStorage: (Uri) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
onMediaWrittenToMultiSessionStorage(AvatarPickerStorage.save(applicationContext, media))
|
||||
}
|
||||
}
|
||||
|
||||
fun persistAvatarForSelf(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
|
||||
val savedAvatar = avatarDatabase.saveAvatarForSelf(avatar)
|
||||
avatarDatabase.markUsage(savedAvatar)
|
||||
onPersisted(savedAvatar)
|
||||
}
|
||||
}
|
||||
|
||||
fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId, onPersisted: (Avatar) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
|
||||
val savedAvatar = avatarDatabase.saveAvatarForGroup(avatar, groupId)
|
||||
avatarDatabase.markUsage(savedAvatar)
|
||||
onPersisted(savedAvatar)
|
||||
}
|
||||
}
|
||||
|
||||
fun persistAndCreateMediaForSelf(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
|
||||
persistAvatarForSelf(avatar) {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
} else {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId, onSaved: (Media) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
|
||||
persistAvatarForGroup(avatar, groupId) {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
} else {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createMediaForNewGroup(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleRenderFailure(throwable: Throwable?) {
|
||||
Log.w(TAG, "Failed to render avatar.", throwable)
|
||||
ThreadUtil.postToMain {
|
||||
Toast.makeText(applicationContext, R.string.AvatarPickerRepository__failed_to_save_avatar, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultAvatarForSelf(): Avatar {
|
||||
val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(applicationContext))
|
||||
|
||||
return if (initials.isNullOrBlank()) {
|
||||
Avatar.getDefaultForSelf()
|
||||
} else {
|
||||
Avatar.Text(initials, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
|
||||
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
|
||||
|
||||
return getDefaultAvatarForGroup(recipient.avatarColor)
|
||||
}
|
||||
|
||||
fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar {
|
||||
val colorPair = Avatars.colorMap[color?.serialize()]
|
||||
val defaultColor = Avatar.getDefaultForGroup()
|
||||
|
||||
return if (colorPair != null) {
|
||||
defaultColor.copy(color = colorPair)
|
||||
} else {
|
||||
defaultColor
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(avatar: Avatar, onDelete: () -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
if (avatar.databaseId is Avatar.DatabaseId.Saved) {
|
||||
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
|
||||
avatarDatabase.deleteAvatar(avatar)
|
||||
}
|
||||
onDelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
|
||||
data class AvatarPickerState(
|
||||
val currentAvatar: Avatar? = null,
|
||||
val selectableAvatars: List<Avatar> = listOf(),
|
||||
val canSave: Boolean = false,
|
||||
val canClear: Boolean = false,
|
||||
val isCleared: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,198 @@
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepository) : ViewModel() {
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val store = Store(AvatarPickerState())
|
||||
|
||||
val state: LiveData<AvatarPickerState> = store.stateLiveData
|
||||
|
||||
protected abstract fun getAvatar(): Single<Avatar>
|
||||
protected abstract fun getDefaultAvatarFromRepository(): Avatar
|
||||
protected abstract fun getPersistedAvatars(): Single<List<Avatar>>
|
||||
protected abstract fun getDefaultAvatars(): Single<List<Avatar>>
|
||||
protected abstract fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit)
|
||||
protected abstract fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit)
|
||||
|
||||
fun delete(avatar: Avatar) {
|
||||
repository.delete(avatar) {
|
||||
refreshAvatar()
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
store.update {
|
||||
val avatar = getDefaultAvatarFromRepository()
|
||||
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) {
|
||||
if (store.state.isCleared) {
|
||||
onCleared()
|
||||
} else {
|
||||
val avatar = store.state.currentAvatar ?: throw AssertionError()
|
||||
persistAndCreateMedia(avatar, onSaved)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAvatarSelectedFromGrid(avatar: Avatar) {
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
|
||||
}
|
||||
|
||||
fun onAvatarEditCompleted(avatar: Avatar) {
|
||||
persistAvatar(avatar) { saved ->
|
||||
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
}
|
||||
|
||||
fun onAvatarPhotoSelectionCompleted(media: Media) {
|
||||
repository.writeMediaToMultiSessionStorage(media) { multiSessionUri ->
|
||||
persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar ->
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun refreshAvatar() {
|
||||
disposables.add(
|
||||
getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar ->
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
protected fun refreshSelectableAvatars() {
|
||||
disposables.add(
|
||||
Single.zip(getPersistedAvatars(), getDefaultAvatars()) { custom, def ->
|
||||
val customKeys = custom.filterIsInstance(Avatar.Vector::class.java).map { it.key }
|
||||
custom + def.filterNot {
|
||||
it is Avatar.Vector && customKeys.contains(it.key)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io()).subscribe { avatars ->
|
||||
store.update { it.copy(selectableAvatars = avatars) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun isSaveable(avatar: Avatar) = avatar.databaseId != Avatar.DatabaseId.DoNotPersist
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
private class SelfAvatarPickerViewModel(private val repository: AvatarPickerRepository) : AvatarPickerViewModel(repository) {
|
||||
|
||||
init {
|
||||
refreshAvatar()
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
|
||||
override fun getAvatar(): Single<Avatar> = repository.getAvatarForSelf()
|
||||
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf()
|
||||
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForSelf()
|
||||
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForSelf()
|
||||
|
||||
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
|
||||
repository.persistAvatarForSelf(avatar, onPersisted)
|
||||
}
|
||||
|
||||
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||
repository.persistAndCreateMediaForSelf(avatar, onSaved)
|
||||
}
|
||||
}
|
||||
|
||||
private class GroupAvatarPickerViewModel(
|
||||
private val groupId: GroupId,
|
||||
private val repository: AvatarPickerRepository,
|
||||
groupAvatarMedia: Media?
|
||||
) : AvatarPickerViewModel(repository) {
|
||||
|
||||
private val initialAvatar: Avatar? = groupAvatarMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
|
||||
|
||||
init {
|
||||
refreshAvatar()
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
|
||||
override fun getAvatar(): Single<Avatar> {
|
||||
return if (initialAvatar != null) {
|
||||
Single.just(initialAvatar)
|
||||
} else {
|
||||
repository.getAvatarForGroup(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId)
|
||||
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
|
||||
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
|
||||
|
||||
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
|
||||
repository.persistAvatarForGroup(avatar, groupId, onPersisted)
|
||||
}
|
||||
|
||||
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||
repository.persistAndCreateMediaForGroup(avatar, groupId, onSaved)
|
||||
}
|
||||
}
|
||||
|
||||
private class NewGroupAvatarPickerViewModel(
|
||||
private val repository: AvatarPickerRepository,
|
||||
initialMedia: Media?
|
||||
) : AvatarPickerViewModel(repository) {
|
||||
|
||||
private val initialAvatar: Avatar? = initialMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
|
||||
|
||||
init {
|
||||
refreshAvatar()
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
|
||||
override fun getAvatar(): Single<Avatar> {
|
||||
return if (initialAvatar != null) {
|
||||
Single.just(initialAvatar)
|
||||
} else {
|
||||
Single.fromCallable { getDefaultAvatarFromRepository() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null)
|
||||
override fun getPersistedAvatars(): Single<List<Avatar>> = Single.just(listOf())
|
||||
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
|
||||
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar)
|
||||
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) = repository.createMediaForNewGroup(avatar, onSaved)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: AvatarPickerRepository,
|
||||
private val groupId: GroupId?,
|
||||
private val isNewGroup: Boolean,
|
||||
private val groupAvatarMedia: Media?
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
val viewModel = if (groupId == null && !isNewGroup) {
|
||||
SelfAvatarPickerViewModel(repository)
|
||||
} else if (groupId == null) {
|
||||
NewGroupAvatarPickerViewModel(repository, groupAvatarMedia)
|
||||
} else {
|
||||
GroupAvatarPickerViewModel(groupId, repository, groupAvatarMedia)
|
||||
}
|
||||
|
||||
return requireNotNull(modelClass.cast(viewModel))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.thoughtcrime.securesms.avatar.text
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.AutoTransition
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import org.signal.core.util.EditTextUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerItem
|
||||
import org.thoughtcrime.securesms.components.BoldSelectionTabItem
|
||||
import org.thoughtcrime.securesms.components.ControllableTabLayout
|
||||
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Fragment to create an avatar based off of a Vector or Text (via a pager)
|
||||
*/
|
||||
class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragment) {
|
||||
|
||||
private val viewModel: TextAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
|
||||
|
||||
private lateinit var textInput: EditText
|
||||
private lateinit var recycler: RecyclerView
|
||||
private lateinit var content: ConstraintLayout
|
||||
|
||||
private val withRecyclerSet = ConstraintSet()
|
||||
private val withoutRecyclerSet = ConstraintSet()
|
||||
|
||||
private var hasBoundFromViewModel: Boolean = false
|
||||
|
||||
private fun createFactory(): TextAvatarCreationViewModel.Factory {
|
||||
val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments())
|
||||
val textBundle = args.textAvatar
|
||||
val text = if (textBundle != null) {
|
||||
AvatarBundler.extractText(textBundle)
|
||||
} else {
|
||||
Avatar.Text("", Avatars.colors.random(), Avatar.DatabaseId.NotSet)
|
||||
}
|
||||
|
||||
return TextAvatarCreationViewModel.Factory(text)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.text_avatar_creation_toolbar)
|
||||
val tabLayout: ControllableTabLayout = view.findViewById(R.id.text_avatar_creation_tabs)
|
||||
val doneButton: View = view.findViewById(R.id.text_avatar_creation_done)
|
||||
val keyboardAwareLayout: KeyboardAwareLinearLayout = view.findViewById(R.id.keyboard_aware_layout)
|
||||
|
||||
withRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content)
|
||||
withoutRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content_hidden_recycler)
|
||||
|
||||
content = view.findViewById(R.id.content)
|
||||
recycler = view.findViewById(R.id.text_avatar_creation_recycler)
|
||||
textInput = view.findViewById(R.id.avatar_picker_item_text)
|
||||
|
||||
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
|
||||
BoldSelectionTabItem.registerListeners(tabLayout)
|
||||
|
||||
val onTabSelectedListener = OnTabSelectedListener()
|
||||
tabLayout.addOnTabSelectedListener(onTabSelectedListener)
|
||||
onTabSelectedListener.onTabSelected(requireNotNull(tabLayout.getTabAt(tabLayout.selectedTabPosition)))
|
||||
|
||||
val adapter = MappingAdapter()
|
||||
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
|
||||
AvatarColorItem.registerViewHolder(adapter) {
|
||||
viewModel.setColor(it)
|
||||
}
|
||||
recycler.adapter = adapter
|
||||
|
||||
val viewHolder = AvatarPickerItem.ViewHolder(view)
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor)
|
||||
viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
|
||||
|
||||
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
|
||||
hasBoundFromViewModel = true
|
||||
}
|
||||
|
||||
EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3)
|
||||
textInput.doAfterTextChanged {
|
||||
if (it != null && hasBoundFromViewModel) {
|
||||
viewModel.setText(it.toString())
|
||||
}
|
||||
}
|
||||
|
||||
doneButton.setOnClickListener { v ->
|
||||
setFragmentResult(REQUEST_KEY_TEXT, AvatarBundler.bundleText(viewModel.getCurrentAvatar()))
|
||||
Navigation.findNavController(v).popBackStack()
|
||||
}
|
||||
|
||||
textInput.setOnEditorActionListener { v, actionId, event ->
|
||||
if (actionId == EditorInfo.IME_ACTION_NEXT) {
|
||||
tabLayout.getTabAt(1)?.select()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
keyboardAwareLayout.addOnKeyboardHiddenListener {
|
||||
if (tabLayout.selectedTabPosition == 1) {
|
||||
val transition = AutoTransition().setStartDelay(250L)
|
||||
TransitionManager.endTransitions(content)
|
||||
withRecyclerSet.applyTo(content)
|
||||
TransitionManager.beginDelayedTransition(content, transition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
when (tab.position) {
|
||||
0 -> {
|
||||
textInput.isEnabled = true
|
||||
ViewUtil.focusAndShowKeyboard(textInput)
|
||||
|
||||
withoutRecyclerSet.applyTo(content)
|
||||
textInput.setSelection(textInput.length())
|
||||
}
|
||||
1 -> {
|
||||
textInput.isEnabled = false
|
||||
ViewUtil.hideKeyboard(requireContext(), textInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
|
||||
override fun onTabReselected(tab: TabLayout.Tab?) = Unit
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY_TEXT = "org.thoughtcrime.securesms.avatar.text.TEXT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.avatar.text
|
||||
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
|
||||
data class TextAvatarCreationState(
|
||||
val currentAvatar: Avatar.Text,
|
||||
) {
|
||||
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.thoughtcrime.securesms.avatar.text
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
|
||||
|
||||
private val store = Store(TextAvatarCreationState(initialText))
|
||||
|
||||
val state: LiveData<TextAvatarCreationState> = Transformations.distinctUntilChanged(store.stateLiveData)
|
||||
|
||||
fun setColor(colorPair: Avatars.ColorPair) {
|
||||
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
|
||||
}
|
||||
|
||||
fun setText(text: String) {
|
||||
store.update {
|
||||
if (it.currentAvatar.text == text) {
|
||||
it
|
||||
} else {
|
||||
it.copy(currentAvatar = it.currentAvatar.copy(text = text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentAvatar(): Avatar.Text {
|
||||
return store.state.currentAvatar
|
||||
}
|
||||
|
||||
class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.thoughtcrime.securesms.avatar.vector
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Fragment to create an avatar based off a default vector.
|
||||
*/
|
||||
class VectorAvatarCreationFragment : Fragment(R.layout.vector_avatar_creation_fragment) {
|
||||
|
||||
private val viewModel: VectorAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
|
||||
|
||||
private fun createFactory(): VectorAvatarCreationViewModel.Factory {
|
||||
val args = VectorAvatarCreationFragmentArgs.fromBundle(requireArguments())
|
||||
val vectorBundle = args.vectorAvatar
|
||||
|
||||
return VectorAvatarCreationViewModel.Factory(AvatarBundler.extractVector(vectorBundle))
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.vector_avatar_creation_toolbar)
|
||||
val recycler: RecyclerView = view.findViewById(R.id.vector_avatar_creation_recycler)
|
||||
val doneButton: View = view.findViewById(R.id.vector_avatar_creation_done)
|
||||
val preview: ImageView = view.findViewById(R.id.vector_avatar_creation_image)
|
||||
|
||||
val adapter = MappingAdapter()
|
||||
recycler.adapter = adapter
|
||||
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
|
||||
AvatarColorItem.registerViewHolder(adapter) {
|
||||
viewModel.setColor(it)
|
||||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
preview.background.colorFilter = SimpleColorFilter(state.currentAvatar.color.backgroundColor)
|
||||
preview.setImageResource(requireNotNull(Avatars.getDrawableResource(state.currentAvatar.key)))
|
||||
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener { Navigation.findNavController(view).popBackStack() }
|
||||
doneButton.setOnClickListener {
|
||||
setFragmentResult(REQUEST_KEY_VECTOR, AvatarBundler.bundleVector(viewModel.getCurrentAvatar()))
|
||||
Navigation.findNavController(it).popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY_VECTOR = "org.thoughtcrime.securesms.avatar.text.VECTOR"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.avatar.vector
|
||||
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
|
||||
data class VectorAvatarCreationState(
|
||||
val currentAvatar: Avatar.Vector,
|
||||
) {
|
||||
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.thoughtcrime.securesms.avatar.vector
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel() {
|
||||
|
||||
private val store = Store(VectorAvatarCreationState(initialAvatar))
|
||||
|
||||
val state: LiveData<VectorAvatarCreationState> = store.stateLiveData
|
||||
|
||||
fun setColor(colorPair: Avatars.ColorPair) {
|
||||
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
|
||||
}
|
||||
|
||||
fun getCurrentAvatar() = store.state.currentAvatar
|
||||
|
||||
class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import androidx.documentfile.provider.DocumentFile;
|
||||
import com.annimon.stream.function.Predicate;
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.Conversions;
|
||||
@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.database.SessionDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -84,7 +85,8 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
EmojiSearchDatabase.TABLE_NAME,
|
||||
SenderKeyDatabase.TABLE_NAME,
|
||||
SenderKeySharedDatabase.TABLE_NAME,
|
||||
PendingRetryReceiptDatabase.TABLE_NAME
|
||||
PendingRetryReceiptDatabase.TABLE_NAME,
|
||||
AvatarPickerDatabase.TABLE_NAME
|
||||
);
|
||||
|
||||
public static void export(@NonNull Context context,
|
||||
|
||||
@@ -11,7 +11,7 @@ import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.Conversions;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms.blocked;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
@@ -8,10 +7,12 @@ import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
@@ -25,6 +26,8 @@ import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class BlockedUsersActivity extends PassphraseRequiredActivity implements BlockedUsersFragment.Listener, ContactSelectionListFragment.OnContactSelectedListener {
|
||||
|
||||
private static final String CONTACT_SELECTION_FRAGMENT = "Contact.Selection.Fragment";
|
||||
@@ -84,24 +87,24 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
|
||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||
final String displayName = recipientId.transform(id -> Recipient.resolved(id).getDisplayName(this)).or(number);
|
||||
|
||||
AlertDialog confirmationDialog = new AlertDialog.Builder(BlockedUsersActivity.this)
|
||||
.setTitle(R.string.BlockedUsersActivity__block_user)
|
||||
.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName))
|
||||
.setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> {
|
||||
if (recipientId.isPresent()) {
|
||||
viewModel.block(recipientId.get());
|
||||
} else {
|
||||
viewModel.createAndBlock(number);
|
||||
}
|
||||
dialog.dismiss();
|
||||
onBackPressed();
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
|
||||
.setCancelable(true)
|
||||
.create();
|
||||
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.BlockedUsersActivity__block_user)
|
||||
.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName))
|
||||
.setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> {
|
||||
if (recipientId.isPresent()) {
|
||||
viewModel.block(recipientId.get());
|
||||
} else {
|
||||
viewModel.createAndBlock(number);
|
||||
}
|
||||
dialog.dismiss();
|
||||
onBackPressed();
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
|
||||
.setCancelable(true)
|
||||
.create();
|
||||
|
||||
confirmationDialog.setOnShowListener(dialog -> {
|
||||
confirmationDialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(Color.RED);
|
||||
@@ -109,7 +112,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
|
||||
confirmationDialog.show();
|
||||
|
||||
return false;
|
||||
callback.accept(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
import java.util.Objects;
|
||||
@@ -63,7 +64,7 @@ final class BlockedUsersAdapter extends ListAdapter<Recipient, BlockedUsersAdapt
|
||||
displayName.setText(recipient.getDisplayName(itemView.getContext()));
|
||||
|
||||
if (recipient.hasAUserSetDisplayName(itemView.getContext())) {
|
||||
String identifier = recipient.getE164().or(recipient.getUsername()).orNull();
|
||||
String identifier = recipient.getE164().transform(PhoneNumberFormatter::prettyPrint).or(recipient.getUsername()).orNull();
|
||||
|
||||
if (identifier != null) {
|
||||
numberOrUsername.setText(identifier);
|
||||
|
||||
@@ -346,7 +346,7 @@ public final class AudioView extends FrameLayout {
|
||||
}
|
||||
|
||||
if (duration != null && durationMillis > 0) {
|
||||
long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis);
|
||||
long remainingSecs = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis));
|
||||
duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
|
||||
outlinePaint = ThemeUtil.isDarkTheme(context) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT;
|
||||
|
||||
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN.colorInt(), inverted);
|
||||
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN, inverted);
|
||||
blurred = false;
|
||||
chatColors = null;
|
||||
}
|
||||
@@ -248,7 +248,7 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
requestManager.clear(this);
|
||||
if (fallbackPhotoProvider != null) {
|
||||
setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName()
|
||||
.asDrawable(getContext(), AvatarColor.UNKNOWN.colorInt(), inverted));
|
||||
.asDrawable(getContext(), AvatarColor.UNKNOWN, inverted));
|
||||
} else {
|
||||
setImageDrawable(unknownRecipientDrawable);
|
||||
}
|
||||
@@ -285,7 +285,7 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
{
|
||||
Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
|
||||
.getPhotoForGroup()
|
||||
.asDrawable(getContext(), color.colorInt());
|
||||
.asDrawable(getContext(), color);
|
||||
|
||||
GlideApp.with(this)
|
||||
.load(avatarBytes)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import org.thoughtcrime.securesms.R
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
* Custom View for Tabs which will render bold text when the view is selected
|
||||
*/
|
||||
class BoldSelectionTabItem @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private lateinit var unselectedTextView: TextView
|
||||
private lateinit var selectedTextView: TextView
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
|
||||
unselectedTextView = findViewById(android.R.id.text1)
|
||||
selectedTextView = findViewById(R.id.text1_bold)
|
||||
|
||||
unselectedTextView.doAfterTextChanged {
|
||||
selectedTextView.text = it
|
||||
}
|
||||
}
|
||||
|
||||
fun select() {
|
||||
unselectedTextView.alpha = 0f
|
||||
selectedTextView.alpha = 1f
|
||||
}
|
||||
|
||||
fun unselect() {
|
||||
unselectedTextView.alpha = 1f
|
||||
selectedTextView.alpha = 0f
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun registerListeners(tabLayout: ControllableTabLayout) {
|
||||
val newTabListener = NewTabListener()
|
||||
val onTabSelectedListener = OnTabSelectedListener()
|
||||
|
||||
(0 until tabLayout.tabCount).mapNotNull { tabLayout.getTabAt(it) }.forEach {
|
||||
newTabListener.onNewTab(it)
|
||||
|
||||
if (it.isSelected) {
|
||||
onTabSelectedListener.onTabSelected(it)
|
||||
} else {
|
||||
onTabSelectedListener.onTabUnselected(it)
|
||||
}
|
||||
}
|
||||
|
||||
tabLayout.setNewTabListener(newTabListener)
|
||||
tabLayout.addOnTabSelectedListener(onTabSelectedListener)
|
||||
}
|
||||
}
|
||||
|
||||
private class NewTabListener : ControllableTabLayout.NewTabListener {
|
||||
override fun onNewTab(tab: TabLayout.Tab) {
|
||||
val customView = tab.customView
|
||||
if (customView == null) {
|
||||
tab.setCustomView(R.layout.bold_selection_tab_item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem
|
||||
view.select()
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab) {
|
||||
val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem
|
||||
view.unselect()
|
||||
}
|
||||
|
||||
override fun onTabReselected(tab: TabLayout.Tab) = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class ButtonStripItemView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val iconView: ImageView
|
||||
private val labelView: TextView
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.button_strip_item_view, this)
|
||||
|
||||
iconView = findViewById(R.id.icon)
|
||||
labelView = findViewById(R.id.label)
|
||||
|
||||
val array = context.obtainStyledAttributes(attrs, R.styleable.ButtonStripItemView)
|
||||
|
||||
val icon = array.getDrawable(R.styleable.ButtonStripItemView_bsiv_icon)
|
||||
val contentDescription = array.getString(R.styleable.ButtonStripItemView_bsiv_icon_contentDescription)
|
||||
val label = array.getString(R.styleable.ButtonStripItemView_bsiv_label)
|
||||
|
||||
iconView.setImageDrawable(icon)
|
||||
iconView.contentDescription = contentDescription
|
||||
labelView.text = label
|
||||
|
||||
array.recycle()
|
||||
}
|
||||
|
||||
fun setOnIconClickedListener(onIconClickedListener: (() -> Unit)?) {
|
||||
iconView.setOnClickListener { onIconClickedListener?.invoke() }
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
@@ -101,7 +102,6 @@ public final class ContactFilterView extends FrameLayout {
|
||||
|
||||
expandTapArea(toggleContainer, dialpadToggle);
|
||||
applyAttributes(searchText, context, attrs, defStyleAttr);
|
||||
searchText.requestFocus();
|
||||
}
|
||||
|
||||
private void applyAttributes(@NonNull EditText searchText,
|
||||
@@ -121,6 +121,16 @@ public final class ContactFilterView extends FrameLayout {
|
||||
if (!attributes.getBoolean(R.styleable.ContactFilterToolbar_showDialpad, true)) {
|
||||
dialpadToggle.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (attributes.getBoolean(R.styleable.ContactFilterToolbar_cfv_autoFocus, true)) {
|
||||
searchText.requestFocus();
|
||||
}
|
||||
|
||||
int backgroundRes = attributes.getResourceId(R.styleable.ContactFilterToolbar_cfv_background, -1);
|
||||
if (backgroundRes != -1) {
|
||||
findViewById(R.id.background_holder).setBackgroundResource(backgroundRes);
|
||||
}
|
||||
|
||||
attributes.recycle();
|
||||
}
|
||||
|
||||
@@ -137,6 +147,10 @@ public final class ContactFilterView extends FrameLayout {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void setOnSearchInputFocusChangedListener(@Nullable OnFocusChangeListener listener) {
|
||||
searchText.setOnFocusChangeListener(listener);
|
||||
}
|
||||
|
||||
public void setHint(@StringRes int hint) {
|
||||
searchText.setHint(hint);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import com.airbnb.lottie.LottieProperty;
|
||||
import com.airbnb.lottie.model.KeyPath;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -33,15 +32,18 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
|
||||
import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ConversationItemFooter extends ConstraintLayout {
|
||||
@@ -63,6 +65,8 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
private final Rect speedToggleHitRect = new Rect();
|
||||
private final int touchTargetSize = ViewUtil.dpToPx(48);
|
||||
|
||||
private long previousMessageId;
|
||||
|
||||
public ConversationItemFooter(Context context) {
|
||||
super(context);
|
||||
init(null);
|
||||
@@ -146,7 +150,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
}
|
||||
|
||||
public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) {
|
||||
long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(totalDurationMillis - currentPostionMillis);
|
||||
long remainingSecs = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(totalDurationMillis - currentPostionMillis));
|
||||
audioDuration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
|
||||
}
|
||||
|
||||
@@ -211,6 +215,10 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
public TextView getDateView() {
|
||||
return dateView;
|
||||
}
|
||||
|
||||
private void notifyTouchDelegateChanged(@NonNull Rect rect, @NonNull View touchDelegate) {
|
||||
if (onTouchDelegateChangedListener != null) {
|
||||
onTouchDelegateChangedListener.onTouchDelegateChanged(rect, touchDelegate);
|
||||
@@ -307,7 +315,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
} else if (messageRecord.isRateLimited()) {
|
||||
dateView.setText(R.string.ConversationItem_send_paused);
|
||||
} else {
|
||||
dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
|
||||
dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +375,19 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
}
|
||||
|
||||
private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) {
|
||||
long newMessageId = buildMessageId(messageRecord);
|
||||
|
||||
if (previousMessageId == newMessageId && deliveryStatusView.isPending() && !messageRecord.isPending()) {
|
||||
if (messageRecord.getRecipient().isGroup()) {
|
||||
SignalLocalMetrics.GroupMessageSend.onUiUpdated(messageRecord.getId());
|
||||
} else {
|
||||
SignalLocalMetrics.IndividualMessageSend.onUiUpdated(messageRecord.getId());
|
||||
}
|
||||
}
|
||||
|
||||
previousMessageId = newMessageId;
|
||||
|
||||
|
||||
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) {
|
||||
deliveryStatusView.setNone();
|
||||
return;
|
||||
@@ -400,7 +421,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
|
||||
showAudioDurationViews();
|
||||
|
||||
if (messageRecord.getViewedReceiptCount() > 0) {
|
||||
if (messageRecord.getViewedReceiptCount() > 0 || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getRecipient(), Recipient.self()))) {
|
||||
revealDot.setProgress(1f);
|
||||
} else {
|
||||
revealDot.setProgress(0f);
|
||||
@@ -425,6 +446,10 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
playbackSpeedToggleTextView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private long buildMessageId(@NonNull MessageRecord record) {
|
||||
return record.isMms() ? -record.getId() : record.getId();
|
||||
}
|
||||
|
||||
public interface OnTouchDelegateChangedListener {
|
||||
void onTouchDelegateChanged(@NonNull Rect delegateRect, @NonNull View delegateView);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ public class DeliveryStatusView extends FrameLayout {
|
||||
this.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public boolean isPending() {
|
||||
return pendingIndicator.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
public void setPending() {
|
||||
this.setVisibility(View.VISIBLE);
|
||||
pendingIndicator.setVisibility(View.VISIBLE);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.google.android.material.shape.CornerFamily
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Forces rounded corners on BottomSheet
|
||||
*/
|
||||
abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFragment() {
|
||||
|
||||
protected open val peekHeightPercentage: Float = 0.5f
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, R.style.Widget_Signal_FixedRoundedCorners)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
||||
|
||||
dialog.behavior.peekHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
|
||||
|
||||
val shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||
.setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
|
||||
.setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
|
||||
.build()
|
||||
|
||||
val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
|
||||
|
||||
dialogBackground.setTint(ContextCompat.getColor(requireContext(), R.color.signal_background_dialog))
|
||||
|
||||
dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (bottomSheet.background !== dialogBackground) {
|
||||
ViewCompat.setBackground(bottomSheet, dialogBackground)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||
})
|
||||
|
||||
return dialog
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.text.style.MetricAffectingSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.text.style.TypefaceSpan;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -17,15 +21,20 @@ import androidx.core.content.ContextCompat;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class FromTextView extends EmojiTextView {
|
||||
public class FromTextView extends SimpleEmojiTextView {
|
||||
|
||||
private static final String TAG = Log.tag(FromTextView.class);
|
||||
|
||||
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
|
||||
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL);
|
||||
|
||||
public FromTextView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
@@ -45,20 +54,9 @@ public class FromTextView extends EmojiTextView {
|
||||
public void setText(Recipient recipient, boolean read, @Nullable String suffix) {
|
||||
String fromString = recipient.getDisplayName(getContext());
|
||||
|
||||
int typeface;
|
||||
|
||||
if (!read) {
|
||||
typeface = Typeface.BOLD;
|
||||
} else {
|
||||
typeface = Typeface.NORMAL;
|
||||
}
|
||||
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
|
||||
SpannableString fromSpan = new SpannableString(fromString);
|
||||
fromSpan.setSpan(new StyleSpan(typeface), 0, builder.length(),
|
||||
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
|
||||
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
SpannableString fromSpan = new SpannableString(fromString);
|
||||
fromSpan.setSpan(getFontSpan(!read), 0, fromSpan.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
|
||||
|
||||
if (recipient.isSelf()) {
|
||||
builder.append(getContext().getString(R.string.note_to_self));
|
||||
@@ -85,4 +83,8 @@ public class FromTextView extends EmojiTextView {
|
||||
|
||||
return mutedDrawable;
|
||||
}
|
||||
|
||||
private CharacterStyle getFontSpan(boolean isBold) {
|
||||
return isBold ? SpanUtil.getBoldSpan() : SpanUtil.getNormalSpan();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ public class InputPanel extends LinearLayout
|
||||
private @Nullable Listener listener;
|
||||
private boolean emojiVisible;
|
||||
|
||||
private boolean hideForGroupState;
|
||||
private boolean hideForBlockedState;
|
||||
private boolean hideForSearch;
|
||||
|
||||
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
|
||||
|
||||
public InputPanel(Context context) {
|
||||
@@ -317,6 +321,21 @@ public class InputPanel extends LinearLayout
|
||||
}
|
||||
}
|
||||
|
||||
public void setHideForGroupState(boolean hideForGroupState) {
|
||||
this.hideForGroupState = hideForGroupState;
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
public void setHideForBlockedState(boolean hideForBlockedState) {
|
||||
this.hideForBlockedState = hideForBlockedState;
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
public void setHideForSearch(boolean hideForSearch) {
|
||||
this.hideForSearch = hideForSearch;
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecordPermissionRequired() {
|
||||
if (listener != null) listener.onRecorderPermissionRequired();
|
||||
@@ -495,6 +514,14 @@ public class InputPanel extends LinearLayout
|
||||
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
}
|
||||
|
||||
private void updateVisibility() {
|
||||
if (hideForGroupState || hideForBlockedState || hideForSearch) {
|
||||
setVisibility(GONE);
|
||||
} else {
|
||||
setVisibility(VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
public interface Listener extends VoiceNoteDraftView.Listener {
|
||||
void onRecorderStarted();
|
||||
void onRecorderLocked();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Fullscreen Dialog Fragment which will dismiss itself when the keyboard is closed
|
||||
*/
|
||||
abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
|
||||
DialogFragment(contentLayoutId),
|
||||
KeyboardAwareLinearLayout.OnKeyboardShownListener,
|
||||
KeyboardAwareLinearLayout.OnKeyboardHiddenListener {
|
||||
|
||||
private var hasShown = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setStyle(STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet)
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
|
||||
dialog.window?.setDimAmount(0f)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
hasShown = false
|
||||
|
||||
val view = super.onCreateView(inflater, container, savedInstanceState)
|
||||
return if (view is KeyboardAwareLinearLayout) {
|
||||
view.addOnKeyboardShownListener(this)
|
||||
view.addOnKeyboardHiddenListener(this)
|
||||
view
|
||||
} else {
|
||||
throw IllegalStateException("Expected parent of view hierarchy to be keyboard aware.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyboardShown() {
|
||||
hasShown = true
|
||||
}
|
||||
|
||||
override fun onKeyboardHidden() {
|
||||
if (hasShown) {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,12 @@ public class ShapeScrim extends View {
|
||||
private final Paint eraser;
|
||||
private final ShapeType shape;
|
||||
private final float radius;
|
||||
private final int canvasColor;
|
||||
|
||||
private Bitmap scrim;
|
||||
private Canvas scrimCanvas;
|
||||
private int scrimWidth;
|
||||
private int scrimHeight;
|
||||
|
||||
public ShapeScrim(Context context) {
|
||||
this(context, null);
|
||||
@@ -57,13 +60,30 @@ public class ShapeScrim extends View {
|
||||
this.eraser = new Paint();
|
||||
this.eraser.setColor(0xFFFFFFFF);
|
||||
this.eraser.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
|
||||
|
||||
this.canvasColor = Color.parseColor("#55BDBDBD");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
||||
super.onLayout(changed, l, t, r, b);
|
||||
int shortDimension = Math.min(getWidth(), getHeight());
|
||||
float drawRadius = shortDimension * radius;
|
||||
|
||||
float left = (getMeasuredWidth() / 2 ) - drawRadius;
|
||||
float top = (getMeasuredHeight() / 2) - drawRadius;
|
||||
float right = left + (drawRadius * 2);
|
||||
float bottom = top + (drawRadius * 2);
|
||||
|
||||
scrimWidth = (int) (right - left);
|
||||
scrimHeight = (int) (bottom - top);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
|
||||
int shortDimension = getWidth() < getHeight() ? getWidth() : getHeight();
|
||||
int shortDimension = Math.min(getWidth(), getHeight());
|
||||
float drawRadius = shortDimension * radius;
|
||||
|
||||
if (scrimCanvas == null) {
|
||||
@@ -72,7 +92,7 @@ public class ShapeScrim extends View {
|
||||
}
|
||||
|
||||
scrim.eraseColor(Color.TRANSPARENT);
|
||||
scrimCanvas.drawColor(Color.parseColor("#55BDBDBD"));
|
||||
scrimCanvas.drawColor(canvasColor);
|
||||
|
||||
if (shape == ShapeType.CIRCLE) drawCircle(scrimCanvas, drawRadius, eraser);
|
||||
else drawSquare(scrimCanvas, drawRadius, eraser);
|
||||
@@ -104,4 +124,12 @@ public class ShapeScrim extends View {
|
||||
|
||||
canvas.drawRoundRect(square, 25, 25, eraser);
|
||||
}
|
||||
|
||||
public int getScrimWidth() {
|
||||
return scrimWidth;
|
||||
}
|
||||
|
||||
public int getScrimHeight() {
|
||||
return scrimHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +143,9 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
if (layoutManager instanceof GridLayoutManager) {
|
||||
int viewWidth = w - getPaddingStart() - getPaddingEnd();
|
||||
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
|
||||
int spanCount = Math.max(w / idealWidth, 1);
|
||||
int spanCount = Math.max(viewWidth / idealWidth, 1);
|
||||
|
||||
((GridLayoutManager) layoutManager).setSpanCount(spanCount);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.emoji.EmojiPageCache;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties;
|
||||
import org.thoughtcrime.securesms.util.FutureTaskListener;
|
||||
import org.thoughtcrime.securesms.util.ListenableFutureTask;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
@@ -52,7 +53,7 @@ class EmojiProvider {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder(text);
|
||||
|
||||
for (EmojiParser.Candidate candidate : matches) {
|
||||
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo());
|
||||
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout);
|
||||
|
||||
if (drawable != null) {
|
||||
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
|
||||
@@ -65,10 +66,17 @@ class EmojiProvider {
|
||||
|
||||
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji) {
|
||||
EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
|
||||
return getEmojiDrawable(context, drawInfo);
|
||||
return getEmojiDrawable(context, drawInfo, null);
|
||||
}
|
||||
|
||||
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo) {
|
||||
/**
|
||||
* Gets an EmojiDrawable from the Page Cache
|
||||
*
|
||||
* @param context Context object used in reading and writing from disk
|
||||
* @param drawInfo Information about the emoji being displayed
|
||||
* @param onEmojiLoaded Runnable which will trigger when an emoji is loaded from disk
|
||||
*/
|
||||
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded) {
|
||||
if (drawInfo == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -77,19 +85,30 @@ class EmojiProvider {
|
||||
final EmojiSource source = EmojiSource.getLatest();
|
||||
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
|
||||
|
||||
EmojiPageCache.INSTANCE
|
||||
.load(context, drawInfo.getPage(), lowMemoryDecodeScale)
|
||||
.addListener(new FutureTaskListener<Bitmap>() {
|
||||
@Override
|
||||
public void onSuccess(Bitmap result) {
|
||||
ThreadUtil.runOnMain(() -> drawable.setBitmap(result));
|
||||
}
|
||||
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException exception) {
|
||||
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
|
||||
}
|
||||
});
|
||||
if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
|
||||
ThreadUtil.runOnMain(() -> drawable.setBitmap(((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap()));
|
||||
} else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
|
||||
((EmojiPageCache.LoadResult.Async) loadResult).getTask().addListener(new FutureTaskListener<Bitmap>() {
|
||||
@Override
|
||||
public void onSuccess(Bitmap result) {
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
drawable.setBitmap(result);
|
||||
if (onEmojiLoaded != null) {
|
||||
onEmojiLoaded.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException exception) {
|
||||
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
|
||||
}
|
||||
|
||||
return drawable;
|
||||
}
|
||||
@@ -122,10 +141,10 @@ class EmojiProvider {
|
||||
final int xStart = (index % emojiPerRow) * glyphWidth;
|
||||
final int yStart = (index / emojiPerRow) * glyphHeight;
|
||||
|
||||
this.emojiBounds = new Rect(xStart,
|
||||
yStart,
|
||||
xStart + glyphWidth,
|
||||
yStart + glyphHeight);
|
||||
this.emojiBounds = new Rect(xStart + 1,
|
||||
yStart + 1,
|
||||
xStart + glyphWidth - 1,
|
||||
yStart + glyphHeight - 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -14,29 +14,33 @@ public class EmojiSpan extends AnimatingImageSpan {
|
||||
|
||||
private final float SHIFT_FACTOR = 1.5f;
|
||||
|
||||
private final int size;
|
||||
private final FontMetricsInt fm;
|
||||
private int size;
|
||||
private FontMetricsInt fontMetrics;
|
||||
|
||||
public EmojiSpan(@NonNull Drawable drawable, @NonNull TextView tv) {
|
||||
super(drawable, tv);
|
||||
fm = tv.getPaint().getFontMetricsInt();
|
||||
size = fm != null ? Math.abs(fm.descent) + Math.abs(fm.ascent)
|
||||
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
|
||||
fontMetrics = tv.getPaint().getFontMetricsInt();
|
||||
size = fontMetrics != null ? Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)
|
||||
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
|
||||
getDrawable().setBounds(0, 0, size, size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, FontMetricsInt fm) {
|
||||
if (fm != null && this.fm != null) {
|
||||
fm.ascent = this.fm.ascent;
|
||||
fm.descent = this.fm.descent;
|
||||
fm.top = this.fm.top;
|
||||
fm.bottom = this.fm.bottom;
|
||||
fm.leading = this.fm.leading;
|
||||
return size;
|
||||
if (fm != null && this.fontMetrics != null) {
|
||||
fm.ascent = this.fontMetrics.ascent;
|
||||
fm.descent = this.fontMetrics.descent;
|
||||
fm.top = this.fontMetrics.top;
|
||||
fm.bottom = this.fontMetrics.bottom;
|
||||
fm.leading = this.fontMetrics.leading;
|
||||
} else {
|
||||
return super.getSize(paint, text, start, end, fm);
|
||||
this.fontMetrics = paint.getFontMetricsInt();
|
||||
this.size = Math.abs(this.fontMetrics.descent) + Math.abs(this.fontMetrics.ascent);
|
||||
|
||||
getDrawable().setBounds(0, 0, size, size);
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -5,8 +5,11 @@ import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.Annotation;
|
||||
import android.text.Layout;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextDirectionHeuristic;
|
||||
import android.text.TextDirectionHeuristics;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
@@ -33,19 +36,23 @@ import java.util.List;
|
||||
public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
private final boolean scaleEmojis;
|
||||
private final boolean forceCustom;
|
||||
|
||||
private static final char ELLIPSIS = '…';
|
||||
|
||||
private CharSequence previousText;
|
||||
private BufferType previousBufferType;
|
||||
private float originalFontSize;
|
||||
private boolean useSystemEmoji;
|
||||
private boolean sizeChangeInProgress;
|
||||
private int maxLength;
|
||||
private CharSequence overflowText;
|
||||
private CharSequence previousOverflowText;
|
||||
private boolean renderMentions;
|
||||
private boolean forceCustom;
|
||||
private CharSequence previousText;
|
||||
private BufferType previousBufferType;
|
||||
private float originalFontSize;
|
||||
private boolean useSystemEmoji;
|
||||
private boolean sizeChangeInProgress;
|
||||
private int maxLength;
|
||||
private CharSequence overflowText;
|
||||
private CharSequence previousOverflowText;
|
||||
private boolean renderMentions;
|
||||
private boolean measureLastLine;
|
||||
private int lastLineWidth = -1;
|
||||
private TextDirectionHeuristic textDirection;
|
||||
private boolean isJumbomoji;
|
||||
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
|
||||
@@ -61,10 +68,11 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
|
||||
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
|
||||
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
|
||||
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
|
||||
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
|
||||
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
|
||||
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
|
||||
measureLastLine = a.getBoolean(R.styleable.EmojiTextView_measureLastLine, false);
|
||||
a.recycle();
|
||||
|
||||
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
|
||||
@@ -74,6 +82,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
if (renderMentions) {
|
||||
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
|
||||
}
|
||||
|
||||
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -103,8 +113,10 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
if (emojis <= 4) scale += 0.25f;
|
||||
if (emojis <= 2) scale += 0.25f;
|
||||
|
||||
isJumbomoji = scale > 1.0f;
|
||||
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale);
|
||||
} else if (scaleEmojis) {
|
||||
isJumbomoji = false;
|
||||
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize);
|
||||
}
|
||||
|
||||
@@ -139,11 +151,50 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
}
|
||||
|
||||
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
CharSequence text = getText();
|
||||
if (getLayout() == null || !measureLastLine || text == null || text.length() == 0) {
|
||||
lastLineWidth = -1;
|
||||
} else {
|
||||
Layout layout = getLayout();
|
||||
int lines = layout.getLineCount();
|
||||
int start = layout.getLineStart(lines - 1);
|
||||
int count = text.length() - start;
|
||||
|
||||
if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, start, count)) ||
|
||||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, start, count))) {
|
||||
lastLineWidth = getMeasuredWidth();
|
||||
} else {
|
||||
lastLineWidth = (int) getPaint().measureText(text, start, text.length());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int getLastLineWidth() {
|
||||
return lastLineWidth;
|
||||
}
|
||||
|
||||
public boolean isSingleLine() {
|
||||
return getLayout() != null && getLayout().getLineCount() == 1;
|
||||
}
|
||||
|
||||
public boolean isJumbomoji() {
|
||||
return isJumbomoji;
|
||||
}
|
||||
|
||||
public void setOverflowText(@Nullable CharSequence overflowText) {
|
||||
this.overflowText = overflowText;
|
||||
setText(previousText, BufferType.SPANNABLE);
|
||||
}
|
||||
|
||||
public void setForceCustomEmoji(boolean forceCustom) {
|
||||
if (this.forceCustom != forceCustom) {
|
||||
this.forceCustom = forceCustom;
|
||||
setText(previousText, BufferType.SPANNABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void ellipsizeAnyTextForMaxLength() {
|
||||
if (maxLength > 0 && getText().length() > maxLength + 1) {
|
||||
SpannableStringBuilder newContent = new SpannableStringBuilder();
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ContextThemeWrapper;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
@@ -20,6 +21,8 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
|
||||
private static final String TAG = Log.tag(MediaKeyboard.class);
|
||||
@@ -40,6 +43,10 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public void setFragmentManager(@NonNull FragmentManager fragmentManager) {
|
||||
this.fragmentManager = fragmentManager;
|
||||
}
|
||||
|
||||
public void setKeyboardListener(@Nullable MediaKeyboardListener listener) {
|
||||
this.keyboardListener = listener;
|
||||
}
|
||||
@@ -125,13 +132,32 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
|
||||
private void initView() {
|
||||
if (!isInitialised) {
|
||||
|
||||
LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
|
||||
|
||||
if (fragmentManager == null) {
|
||||
FragmentActivity activity = resolveActivity(getContext());
|
||||
fragmentManager = activity.getSupportFragmentManager();
|
||||
}
|
||||
|
||||
keyboardPagerFragment = new KeyboardPagerFragment();
|
||||
fragmentManager.beginTransaction()
|
||||
.replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment)
|
||||
.commitNowAllowingStateLoss();
|
||||
|
||||
keyboardState = State.NORMAL;
|
||||
latestKeyboardHeight = -1;
|
||||
isInitialised = true;
|
||||
fragmentManager = ((FragmentActivity) getContext()).getSupportFragmentManager();
|
||||
keyboardPagerFragment = (KeyboardPagerFragment) fragmentManager.findFragmentById(R.id.media_keyboard_fragment_container);
|
||||
}
|
||||
}
|
||||
|
||||
private static FragmentActivity resolveActivity(@Nullable Context context) {
|
||||
if (context instanceof FragmentActivity) {
|
||||
return (FragmentActivity) context;
|
||||
} else if (context instanceof ContextThemeWrapper) {
|
||||
return resolveActivity(((ContextThemeWrapper) context).getBaseContext());
|
||||
} else {
|
||||
throw new IllegalStateException("Could not locate FragmentActivity");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
|
||||
open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : AppCompatTextView(context, attrs, defStyleAttr) {
|
||||
|
||||
private var bufferType: BufferType? = null
|
||||
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
bufferType = type
|
||||
val candidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
|
||||
if (SignalStore.settings().isPreferSystemEmoji || candidates == null || candidates.size() == 0) {
|
||||
super.setText(Optional.fromNullable(text).or(""), BufferType.NORMAL)
|
||||
} else {
|
||||
val newContent = if (width == 0 || maxLines == -1) {
|
||||
text
|
||||
} else {
|
||||
TextUtils.ellipsize(text, paint, (width * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
|
||||
}
|
||||
|
||||
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(newContent)
|
||||
val newText = if (newCandidates == null || newCandidates.size() == 0) {
|
||||
newContent
|
||||
} else {
|
||||
EmojiProvider.emojify(newCandidates, newContent, this)
|
||||
}
|
||||
super.setText(newText, type)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
||||
super.onSizeChanged(width, height, oldWidth, oldHeight)
|
||||
if (width > 0 && oldWidth != width) {
|
||||
setText(text, bufferType ?: BufferType.NORMAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
|
||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
|
||||
@@ -40,12 +42,12 @@ public class UntrustedSendDialog extends AlertDialog.Builder implements DialogIn
|
||||
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
|
||||
final TextSecureIdentityKeyStore identityStore = ApplicationDependencies.getIdentityStore();
|
||||
|
||||
SimpleTask.run(() -> {
|
||||
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
for (IdentityRecord identityRecord : untrustedRecords) {
|
||||
identityDatabase.setApproval(identityRecord.getRecipientId(), true);
|
||||
identityStore.setApproval(identityRecord.getRecipientId(), true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import androidx.annotation.RequiresApi;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
|
||||
import java.util.List;
|
||||
@@ -39,27 +41,16 @@ public class UnverifiedSendDialog extends AlertDialog.Builder implements DialogI
|
||||
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
|
||||
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
for (IdentityRecord identityRecord : untrustedRecords) {
|
||||
identityDatabase.setVerified(identityRecord.getRecipientId(),
|
||||
identityRecord.getIdentityKey(),
|
||||
IdentityDatabase.VerifiedStatus.DEFAULT);
|
||||
}
|
||||
SimpleTask.run(() -> {
|
||||
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
for (IdentityRecord identityRecord : untrustedRecords) {
|
||||
ApplicationDependencies.getIdentityStore().setVerified(identityRecord.getRecipientId(),
|
||||
identityRecord.getIdentityKey(),
|
||||
IdentityDatabase.VerifiedStatus.DEFAULT);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Void result) {
|
||||
resendListener.onResendMessage();
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
return null;
|
||||
}, nothing -> resendListener.onResendMessage());
|
||||
}
|
||||
|
||||
public interface ResendListener {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.components.recyclerview
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.annotation.Px
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Decoration which will add an equal amount of space between each item in a grid.
|
||||
*/
|
||||
open class GridDividerDecoration(
|
||||
private val spanCount: Int,
|
||||
@Px private val space: Int
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
return setItemOffsets(parent.getChildAdapterPosition(view), view, outRect)
|
||||
}
|
||||
|
||||
protected fun setItemOffsets(position: Int, view: View, outRect: Rect) {
|
||||
val column = position % spanCount
|
||||
val isRtl = ViewUtil.isRtl(view)
|
||||
|
||||
val distanceFromEnd = spanCount - 1 - column
|
||||
|
||||
val spaceStart = (column / spanCount.toFloat()) * space
|
||||
val spaceEnd = (distanceFromEnd / spanCount.toFloat()) * space
|
||||
|
||||
outRect.setStart(spaceStart.toInt(), isRtl)
|
||||
outRect.setEnd(spaceEnd.toInt(), isRtl)
|
||||
outRect.bottom = space
|
||||
}
|
||||
|
||||
private fun Rect.setEnd(end: Int, isRtl: Boolean) {
|
||||
if (isRtl) {
|
||||
left = end
|
||||
} else {
|
||||
right = end
|
||||
}
|
||||
}
|
||||
|
||||
private fun Rect.setStart(start: Int, isRtl: Boolean) {
|
||||
if (isRtl) {
|
||||
right = start
|
||||
} else {
|
||||
left = start
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ public class SmoothScrollingLinearLayoutManager extends LinearLayoutManager {
|
||||
super(context, RecyclerView.VERTICAL, reverseLayout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPredictiveItemAnimations() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void smoothScrollToPosition(@NonNull Context context, int position, float millisecondsPerInch) {
|
||||
final LinearSmoothScroller scroller = new LinearSmoothScroller(context) {
|
||||
@Override
|
||||
|
||||
@@ -39,6 +39,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
.setStartCategoryIndex(intent.getIntExtra(HelpFragment.START_CATEGORY_INDEX, 0))
|
||||
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
|
||||
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
|
||||
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +99,9 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
@JvmStatic
|
||||
fun notifications(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATIONS)
|
||||
|
||||
@JvmStatic
|
||||
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHANGE_NUMBER)
|
||||
|
||||
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
|
||||
return Intent(context, AppSettingsActivity::class.java)
|
||||
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
|
||||
@@ -110,7 +114,8 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
BACKUPS(1),
|
||||
HELP(2),
|
||||
PROXY(3),
|
||||
NOTIFICATIONS(4);
|
||||
NOTIFICATIONS(4),
|
||||
CHANGE_NUMBER(5);
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int?): StartLocation {
|
||||
|
||||
@@ -30,6 +30,8 @@ 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.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
|
||||
@@ -43,6 +45,11 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refreshState()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
viewModel = ViewModelProviders.of(this)[AccountSettingsViewModel::class.java]
|
||||
|
||||
@@ -70,7 +77,8 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_app_protection__pin_reminders),
|
||||
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__youll_be_asked_less_frequently),
|
||||
isChecked = state.pinRemindersEnabled,
|
||||
isChecked = state.hasPin && state.pinRemindersEnabled,
|
||||
isEnabled = state.hasPin,
|
||||
onClick = {
|
||||
setPinRemindersEnabled(!state.pinRemindersEnabled)
|
||||
}
|
||||
@@ -80,6 +88,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,
|
||||
onClick = {
|
||||
setRegistrationLockEnabled(!state.registrationLockEnabled)
|
||||
}
|
||||
@@ -96,6 +105,15 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
|
||||
sectionHeaderPref(R.string.AccountSettingsFragment__account)
|
||||
|
||||
if (FeatureFlags.changeNumber() && Recipient.self().changeNumberCapability == Recipient.Capability.SUPPORTED) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
|
||||
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registration.fragments.BaseAccountLockedFragment
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
|
||||
class ChangeNumberAccountLockedFragment : BaseAccountLockedFragment(R.layout.fragment_change_number_account_locked) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
}
|
||||
|
||||
override fun getViewModel(): BaseRegistrationViewModel {
|
||||
return ChangeNumberUtil.getViewModel(this)
|
||||
}
|
||||
|
||||
override fun onNext() {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class ChangeNumberConfirmFragment : LoggingFragment(R.layout.fragment_change_number_confirm) {
|
||||
private lateinit var viewModel: ChangeNumberViewModel
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel = ChangeNumberUtil.getViewModel(this)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
val confirmMessage: TextView = view.findViewById(R.id.change_number_confirm_new_number_message)
|
||||
confirmMessage.text = getString(R.string.ChangeNumberConfirmFragment__you_are_about_to_change_your_phone_number_from_s_to_s, viewModel.oldNumberState.fullFormattedNumber, viewModel.number.fullFormattedNumber)
|
||||
|
||||
val newNumber: TextView = view.findViewById(R.id.change_number_confirm_new_number)
|
||||
newNumber.text = viewModel.number.fullFormattedNumber
|
||||
|
||||
val editNumber: View = view.findViewById(R.id.change_number_confirm_edit_number)
|
||||
editNumber.setOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
val changeNumber: View = view.findViewById(R.id.change_number_confirm_change_number)
|
||||
changeNumber.setOnClickListener { findNavController().navigate(R.id.action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getCaptchaArguments
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.fragments.BaseEnterCodeFragment
|
||||
|
||||
class ChangeNumberEnterCodeFragment : BaseEnterCodeFragment<ChangeNumberViewModel>(R.layout.fragment_change_number_enter_code) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.title = viewModel.number.fullFormattedNumber
|
||||
toolbar.setNavigationOnClickListener { navigateUp() }
|
||||
|
||||
view.findViewById<View>(R.id.verify_header).setOnClickListener(null)
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
navigateUp()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun navigateUp() {
|
||||
if (SignalStore.misc().isChangeNumberLocked) {
|
||||
startActivity(ChangeNumberLockActivity.createIntent(requireContext()))
|
||||
} else {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getViewModel(): ChangeNumberViewModel {
|
||||
return getViewModel(this)
|
||||
}
|
||||
|
||||
override fun handleSuccessfulVerify() {
|
||||
displaySuccess { changeNumberSuccess() }
|
||||
}
|
||||
|
||||
override fun navigateToCaptcha() {
|
||||
findNavController().navigate(R.id.action_changeNumberEnterCodeFragment_to_captchaFragment, getCaptchaArguments())
|
||||
}
|
||||
|
||||
override fun navigateToRegistrationLock(timeRemaining: Long) {
|
||||
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
|
||||
}
|
||||
|
||||
override fun navigateToKbsAccountLocked() {
|
||||
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.View
|
||||
import android.widget.ScrollView
|
||||
import android.widget.Spinner
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.LabeledEditText
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberViewModel.ContinueStatus
|
||||
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragment
|
||||
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragmentArgs
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationNumberInputController
|
||||
import org.thoughtcrime.securesms.util.Dialogs
|
||||
|
||||
private const val OLD_NUMBER_COUNTRY_SELECT = "old_number_country"
|
||||
private const val NEW_NUMBER_COUNTRY_SELECT = "new_number_country"
|
||||
|
||||
class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_change_number_enter_phone_number) {
|
||||
|
||||
private lateinit var scrollView: ScrollView
|
||||
|
||||
private lateinit var oldNumberCountrySpinner: Spinner
|
||||
private lateinit var oldNumberCountryCode: LabeledEditText
|
||||
private lateinit var oldNumber: LabeledEditText
|
||||
|
||||
private lateinit var newNumberCountrySpinner: Spinner
|
||||
private lateinit var newNumberCountryCode: LabeledEditText
|
||||
private lateinit var newNumber: LabeledEditText
|
||||
|
||||
private lateinit var viewModel: ChangeNumberViewModel
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel = getViewModel(this)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
view.findViewById<View>(R.id.change_number_enter_phone_number_continue).setOnClickListener {
|
||||
onContinue()
|
||||
}
|
||||
|
||||
scrollView = view.findViewById(R.id.change_number_enter_phone_number_scroll)
|
||||
|
||||
oldNumberCountrySpinner = view.findViewById(R.id.change_number_enter_phone_number_old_number_spinner)
|
||||
oldNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_old_number_country_code)
|
||||
oldNumber = view.findViewById(R.id.change_number_enter_phone_number_old_number_number)
|
||||
|
||||
val oldController = RegistrationNumberInputController(
|
||||
requireContext(),
|
||||
oldNumberCountryCode,
|
||||
oldNumber,
|
||||
oldNumberCountrySpinner,
|
||||
false,
|
||||
object : RegistrationNumberInputController.Callbacks {
|
||||
override fun onNumberFocused() {
|
||||
scrollView.postDelayed({ scrollView.smoothScrollTo(0, oldNumber.bottom) }, 250)
|
||||
}
|
||||
|
||||
override fun onNumberInputNext(view: View) {
|
||||
newNumberCountryCode.requestFocus()
|
||||
}
|
||||
|
||||
override fun onNumberInputDone(view: View) = Unit
|
||||
|
||||
override fun onPickCountry(view: View) {
|
||||
val arguments: CountryPickerFragmentArgs = CountryPickerFragmentArgs.Builder().setResultKey(OLD_NUMBER_COUNTRY_SELECT).build()
|
||||
|
||||
findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle())
|
||||
}
|
||||
|
||||
override fun setNationalNumber(number: String) {
|
||||
viewModel.setOldNationalNumber(number)
|
||||
}
|
||||
|
||||
override fun setCountry(countryCode: Int) {
|
||||
viewModel.setOldCountry(countryCode)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
newNumberCountrySpinner = view.findViewById(R.id.change_number_enter_phone_number_new_number_spinner)
|
||||
newNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_new_number_country_code)
|
||||
newNumber = view.findViewById(R.id.change_number_enter_phone_number_new_number_number)
|
||||
|
||||
val newController = RegistrationNumberInputController(
|
||||
requireContext(),
|
||||
newNumberCountryCode,
|
||||
newNumber,
|
||||
newNumberCountrySpinner,
|
||||
true,
|
||||
object : RegistrationNumberInputController.Callbacks {
|
||||
override fun onNumberFocused() {
|
||||
scrollView.postDelayed({ scrollView.smoothScrollTo(0, newNumber.bottom) }, 250)
|
||||
}
|
||||
|
||||
override fun onNumberInputNext(view: View) = Unit
|
||||
|
||||
override fun onNumberInputDone(view: View) {
|
||||
onContinue()
|
||||
}
|
||||
|
||||
override fun onPickCountry(view: View) {
|
||||
val arguments: CountryPickerFragmentArgs = CountryPickerFragmentArgs.Builder().setResultKey(NEW_NUMBER_COUNTRY_SELECT).build()
|
||||
|
||||
findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle())
|
||||
}
|
||||
|
||||
override fun setNationalNumber(number: String) {
|
||||
viewModel.setNewNationalNumber(number)
|
||||
}
|
||||
|
||||
override fun setCountry(countryCode: Int) {
|
||||
viewModel.setNewCountry(countryCode)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(OLD_NUMBER_COUNTRY_SELECT, this) { _, bundle ->
|
||||
viewModel.setOldCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY))
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(NEW_NUMBER_COUNTRY_SELECT, this) { _, bundle ->
|
||||
viewModel.setNewCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY))
|
||||
}
|
||||
|
||||
viewModel.getLiveOldNumber().observe(viewLifecycleOwner, oldController::updateNumber)
|
||||
viewModel.getLiveNewNumber().observe(viewLifecycleOwner, newController::updateNumber)
|
||||
}
|
||||
|
||||
private fun onContinue() {
|
||||
if (TextUtils.isEmpty(oldNumberCountryCode.text)) {
|
||||
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_old_number_country_code), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(oldNumber.text)) {
|
||||
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_old_phone_number), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(newNumberCountryCode.text)) {
|
||||
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_new_number_country_code), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(newNumber.text)) {
|
||||
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_new_phone_number), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
when (viewModel.canContinue()) {
|
||||
ContinueStatus.CAN_CONTINUE -> findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_changePhoneNumberConfirmFragment)
|
||||
ContinueStatus.INVALID_NUMBER -> {
|
||||
Dialogs.showAlertDialog(
|
||||
context, getString(R.string.RegistrationActivity_invalid_number), String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), viewModel.number.e164Number)
|
||||
)
|
||||
}
|
||||
ContinueStatus.OLD_NUMBER_DOESNT_MATCH -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.ChangeNumberEnterPhoneNumberFragment__the_phone_number_you_entered_doesnt_match_your_accounts)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class ChangeNumberFragment : LoggingFragment(R.layout.fragment_change_phone_number) {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
view.findViewById<View>(R.id.change_phone_number_continue).setOnClickListener {
|
||||
findNavController().navigate(R.id.action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import java.util.Objects
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberLockActivity::class.java)
|
||||
|
||||
/**
|
||||
* A captive activity that can determine if an interrupted/erred change number request
|
||||
* caused a disparity between the server and our locally stored number.
|
||||
*/
|
||||
class ChangeNumberLockActivity : PassphraseRequiredActivity() {
|
||||
|
||||
private val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
private lateinit var changeNumberRepository: ChangeNumberRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
dynamicTheme.onCreate(this)
|
||||
disposables.bindTo(lifecycle)
|
||||
|
||||
setContentView(R.layout.activity_change_number_lock)
|
||||
|
||||
changeNumberRepository = ChangeNumberRepository(applicationContext)
|
||||
checkWhoAmI()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
dynamicTheme.onResume(this)
|
||||
}
|
||||
|
||||
override fun onBackPressed() = Unit
|
||||
|
||||
private fun checkWhoAmI() {
|
||||
disposables.add(
|
||||
changeNumberRepository.whoAmI()
|
||||
.flatMap { whoAmI ->
|
||||
if (Objects.equals(whoAmI.number, TextSecurePreferences.getLocalNumber(this))) {
|
||||
Log.i(TAG, "Local and remote numbers match, nothing needs to be done.")
|
||||
Single.just(false)
|
||||
} else {
|
||||
Log.i(TAG, "Local (${TextSecurePreferences.getLocalNumber(this)}) and remote (${whoAmI.number}) numbers do not match, updating local.")
|
||||
changeNumberRepository.changeLocalNumber(whoAmI.number)
|
||||
.map { true }
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy(onSuccess = { onChangeStatusConfirmed() }, onError = this::onFailedToGetChangeNumberStatus)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onChangeStatusConfirmed() {
|
||||
SignalStore.misc().unlockChangeNumber()
|
||||
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.ChangeNumberLockActivity__change_status_confirmed)
|
||||
.setMessage(getString(R.string.ChangeNumberLockActivity__your_number_has_been_confirmed_as_s, PhoneNumberFormatter.prettyPrint(TextSecurePreferences.getLocalNumber(this))))
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
startActivity(MainActivity.clearTop(this))
|
||||
finish()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun onFailedToGetChangeNumberStatus(error: Throwable) {
|
||||
Log.w(TAG, "Unable to determine status of change number", error)
|
||||
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.ChangeNumberLockActivity__change_status_unconfirmed)
|
||||
.setMessage(getString(R.string.ChangeNumberLockActivity__we_could_not_determine_the_status_of_your_change_number_request, error.javaClass.simpleName))
|
||||
.setPositiveButton(R.string.ChangeNumberLockActivity__retry) { _, _ -> checkWhoAmI() }
|
||||
.setNegativeButton(R.string.ChangeNumberLockActivity__leave) { _, _ -> finish() }
|
||||
.setNeutralButton(R.string.ChangeNumberLockActivity__submit_debug_log) { _, _ ->
|
||||
startActivity(Intent(this, SubmitDebugLogActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createIntent(context: Context): Intent {
|
||||
return Intent(context, ChangeNumberLockActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
|
||||
|
||||
class ChangeNumberPinDiffersFragment : LoggingFragment(R.layout.fragment_change_number_pin_differs) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
view.findViewById<View>(R.id.change_number_pin_differs_keep_old_pin).setOnClickListener {
|
||||
changeNumberSuccess()
|
||||
}
|
||||
|
||||
val changePin = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == CreateKbsPinActivity.RESULT_OK) {
|
||||
changeNumberSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
view.findViewById<View>(R.id.change_number_pin_differs_update_pin).setOnClickListener {
|
||||
changePin.launch(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()))
|
||||
}
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.ChangeNumberPinDiffersFragment__keep_old_pin_question)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> changeNumberSuccess() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
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.CircularProgressButtonUtil.cancelSpinning
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
|
||||
class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layout.fragment_change_number_registration_lock) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { navigateUp() }
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
navigateUp()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun getViewModel(): BaseRegistrationViewModel {
|
||||
return ChangeNumberUtil.getViewModel(this)
|
||||
}
|
||||
|
||||
override fun navigateToAccountLocked() {
|
||||
findNavController().navigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberAccountLocked())
|
||||
}
|
||||
|
||||
override fun handleSuccessfulPinEntry(pin: String) {
|
||||
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashing.verifyLocalPinHash(it, pin) } ?: false
|
||||
|
||||
cancelSpinning(pinButton)
|
||||
|
||||
if (pinsDiffer) {
|
||||
findNavController().navigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberPinDiffers())
|
||||
} else {
|
||||
changeNumberSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendEmailToSupport() {
|
||||
val subject = R.string.ChangeNumberRegistrationLockFragment__signal_change_number_need_help_with_pin_for_android_v2_pin
|
||||
|
||||
val body: String = SupportEmailUtil.generateSupportEmailBody(
|
||||
requireContext(),
|
||||
subject,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
CommunicationActions.openEmail(
|
||||
requireContext(),
|
||||
SupportEmailUtil.getSupportEmailAddress(requireContext()),
|
||||
getString(subject),
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
private fun navigateUp() {
|
||||
if (SignalStore.misc().isChangeNumberLocked) {
|
||||
startActivity(ChangeNumberLockActivity.createIntent(requireContext()))
|
||||
} else {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.CertificateType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.pin.KbsRepository
|
||||
import org.thoughtcrime.securesms.pin.KeyBackupSystemWrongPinException
|
||||
import org.thoughtcrime.securesms.pin.TokenData
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.KbsPinData
|
||||
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberRepository::class.java)
|
||||
|
||||
class ChangeNumberRepository(private val context: Context) {
|
||||
|
||||
private val accountManager = ApplicationDependencies.getSignalServiceAccountManager()
|
||||
|
||||
fun changeNumber(code: String, newE164: String): Single<ServiceResponse<VerifyAccountResponse>> {
|
||||
return Single.fromCallable { accountManager.changeNumber(code, newE164, null) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun changeNumber(
|
||||
code: String,
|
||||
newE164: String,
|
||||
pin: String,
|
||||
tokenData: TokenData
|
||||
): Single<ServiceResponse<VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse>> {
|
||||
return Single.fromCallable {
|
||||
try {
|
||||
val kbsData: KbsPinData = KbsRepository.restoreMasterKey(pin, tokenData.enclave, tokenData.basicAuth, tokenData.tokenResponse)!!
|
||||
val registrationLock: String = kbsData.masterKey.deriveRegistrationLock()
|
||||
|
||||
val response: ServiceResponse<VerifyAccountResponse> = accountManager.changeNumber(code, newE164, registrationLock)
|
||||
VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse.from(response, kbsData)
|
||||
} catch (e: KeyBackupSystemWrongPinException) {
|
||||
ServiceResponse.forExecutionError(e)
|
||||
} catch (e: KeyBackupSystemNoDataException) {
|
||||
ServiceResponse.forExecutionError(e)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@Suppress("UsePropertyAccessSyntax")
|
||||
fun whoAmI(): Single<WhoAmIResponse> {
|
||||
return Single.fromCallable { ApplicationDependencies.getSignalServiceAccountManager().getWhoAmI() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun changeLocalNumber(e164: String): Single<Unit> {
|
||||
DatabaseFactory.getRecipientDatabase(context).updateSelfPhone(e164)
|
||||
|
||||
TextSecurePreferences.setLocalNumber(context, e164)
|
||||
|
||||
ApplicationDependencies.closeConnections()
|
||||
ApplicationDependencies.getIncomingMessageObserver()
|
||||
|
||||
return rotateCertificates()
|
||||
}
|
||||
|
||||
@Suppress("UsePropertyAccessSyntax")
|
||||
private fun rotateCertificates(): Single<Unit> {
|
||||
val certificateTypes = SignalStore.phoneNumberPrivacy().allCertificateTypes
|
||||
|
||||
Log.i(TAG, "Rotating these certificates $certificateTypes")
|
||||
|
||||
return Single.fromCallable {
|
||||
for (certificateType in certificateTypes) {
|
||||
val certificate: ByteArray? = when (certificateType) {
|
||||
CertificateType.UUID_AND_E164 -> accountManager.getSenderCertificate()
|
||||
CertificateType.UUID_ONLY -> accountManager.getSenderCertificateForPhoneNumberPrivacy()
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Successfully got $certificateType certificate")
|
||||
|
||||
SignalStore.certificateValues().setUnidentifiedAccessCertificate(certificateType, certificate)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registration.fragments.CaptchaFragment
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
|
||||
/**
|
||||
* Helpers for various aspects of the change number flow.
|
||||
*/
|
||||
object ChangeNumberUtil {
|
||||
@JvmStatic
|
||||
fun getViewModel(fragment: Fragment): ChangeNumberViewModel {
|
||||
val navController = NavHostFragment.findNavController(fragment)
|
||||
return ViewModelProvider(
|
||||
navController.getViewModelStoreOwner(R.id.app_settings_change_number),
|
||||
ChangeNumberViewModel.Factory(navController.getBackStackEntry(R.id.app_settings_change_number))
|
||||
).get(ChangeNumberViewModel::class.java)
|
||||
}
|
||||
|
||||
fun getCaptchaArguments(): Bundle {
|
||||
return Bundle().apply {
|
||||
putSerializable(
|
||||
CaptchaFragment.EXTRA_VIEW_MODEL_PROVIDER,
|
||||
object : CaptchaFragment.CaptchaViewModelProvider {
|
||||
override fun get(fragment: CaptchaFragment): BaseRegistrationViewModel = getViewModel(fragment)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Fragment.changeNumberSuccess() {
|
||||
findNavController().navigate(R.id.action_pop_app_settings_change_number)
|
||||
Toast.makeText(requireContext(), R.string.ChangeNumber__your_phone_number_has_been_changed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getCaptchaArguments
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberVerifyFragment::class.java)
|
||||
|
||||
class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phone_number_verify) {
|
||||
private lateinit var viewModel: ChangeNumberViewModel
|
||||
|
||||
private var requestingCaptcha: Boolean = false
|
||||
|
||||
private val lifecycleDisposable: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lifecycleDisposable.bindTo(lifecycle)
|
||||
viewModel = getViewModel(this)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setTitle(R.string.ChangeNumberVerifyFragment__change_number)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
val status: TextView = view.findViewById(R.id.change_phone_number_verify_status)
|
||||
status.text = getString(R.string.ChangeNumberVerifyFragment__verifying_s, viewModel.number.fullFormattedNumber)
|
||||
|
||||
if (!requestingCaptcha || viewModel.hasCaptchaToken()) {
|
||||
requestCode()
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.ChangeNumberVerifyFragment__captcha_required, Toast.LENGTH_SHORT).show()
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestCode() {
|
||||
lifecycleDisposable.add(
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { processor ->
|
||||
if (processor.hasResult()) {
|
||||
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
|
||||
} else if (processor.localRateLimit()) {
|
||||
Log.i(TAG, "Unable to request sms code due to local rate limit")
|
||||
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
|
||||
} else if (processor.captchaRequired()) {
|
||||
Log.i(TAG, "Unable to request sms code due to captcha required")
|
||||
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_captchaFragment, getCaptchaArguments())
|
||||
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()
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.app.Application
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.AbstractSavedStateViewModelFactory
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import com.google.i18n.phonenumbers.NumberParseException
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.pin.KbsRepository
|
||||
import org.thoughtcrime.securesms.pin.TokenData
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseProcessor
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseWithoutKbs
|
||||
import org.thoughtcrime.securesms.registration.VerifyCodeWithRegistrationLockResponseProcessor
|
||||
import org.thoughtcrime.securesms.registration.VerifyProcessor
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import java.util.Objects
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberViewModel::class.java)
|
||||
|
||||
class ChangeNumberViewModel(
|
||||
private val localNumber: String,
|
||||
private val changeNumberRepository: ChangeNumberRepository,
|
||||
savedState: SavedStateHandle,
|
||||
password: String,
|
||||
verifyAccountRepository: VerifyAccountRepository,
|
||||
kbsRepository: KbsRepository,
|
||||
) : BaseRegistrationViewModel(savedState, verifyAccountRepository, kbsRepository, password) {
|
||||
|
||||
var oldNumberState: NumberViewState = NumberViewState.Builder().build()
|
||||
private set
|
||||
|
||||
private val liveOldNumberState = DefaultValueLiveData(oldNumberState)
|
||||
private val liveNewNumberState = DefaultValueLiveData(number)
|
||||
|
||||
init {
|
||||
try {
|
||||
val countryCode: Int = PhoneNumberUtil.getInstance()
|
||||
.parse(localNumber, null)
|
||||
.countryCode
|
||||
|
||||
setOldCountry(countryCode)
|
||||
setNewCountry(countryCode)
|
||||
} catch (e: NumberParseException) {
|
||||
Log.i(TAG, "Unable to parse number for default country code")
|
||||
}
|
||||
}
|
||||
|
||||
fun getLiveOldNumber(): LiveData<NumberViewState> {
|
||||
return liveOldNumberState
|
||||
}
|
||||
|
||||
fun getLiveNewNumber(): LiveData<NumberViewState> {
|
||||
return liveNewNumberState
|
||||
}
|
||||
|
||||
fun setOldNationalNumber(number: String) {
|
||||
oldNumberState = oldNumberState.toBuilder()
|
||||
.nationalNumber(number)
|
||||
.build()
|
||||
|
||||
liveOldNumberState.value = oldNumberState
|
||||
}
|
||||
|
||||
fun setOldCountry(countryCode: Int, country: String? = null) {
|
||||
oldNumberState = oldNumberState.toBuilder()
|
||||
.selectedCountryDisplayName(country)
|
||||
.countryCode(countryCode)
|
||||
.build()
|
||||
|
||||
liveOldNumberState.value = oldNumberState
|
||||
}
|
||||
|
||||
fun setNewNationalNumber(number: String) {
|
||||
setNationalNumber(number)
|
||||
|
||||
liveNewNumberState.value = this.number
|
||||
}
|
||||
|
||||
fun setNewCountry(countryCode: Int, country: String? = null) {
|
||||
onCountrySelected(country, countryCode)
|
||||
|
||||
liveNewNumberState.value = this.number
|
||||
}
|
||||
|
||||
fun canContinue(): ContinueStatus {
|
||||
return if (oldNumberState.e164Number == localNumber) {
|
||||
if (number.isValid) {
|
||||
ContinueStatus.CAN_CONTINUE
|
||||
} else {
|
||||
ContinueStatus.INVALID_NUMBER
|
||||
}
|
||||
} else {
|
||||
ContinueStatus.OLD_NUMBER_DOESNT_MATCH
|
||||
}
|
||||
}
|
||||
|
||||
override fun verifyCodeWithoutRegistrationLock(code: String): Single<VerifyAccountResponseProcessor> {
|
||||
return super.verifyCodeWithoutRegistrationLock(code)
|
||||
.doOnSubscribe { SignalStore.misc().lockChangeNumber() }
|
||||
.flatMap(this::attemptToUnlockChangeNumber)
|
||||
}
|
||||
|
||||
override fun verifyCodeAndRegisterAccountWithRegistrationLock(pin: String): Single<VerifyCodeWithRegistrationLockResponseProcessor> {
|
||||
return super.verifyCodeAndRegisterAccountWithRegistrationLock(pin)
|
||||
.doOnSubscribe { SignalStore.misc().lockChangeNumber() }
|
||||
.flatMap(this::attemptToUnlockChangeNumber)
|
||||
}
|
||||
|
||||
private fun <T : VerifyProcessor> attemptToUnlockChangeNumber(processor: T): Single<T> {
|
||||
return if (processor.hasResult() || processor.isServerSentError()) {
|
||||
SignalStore.misc().unlockChangeNumber()
|
||||
Single.just(processor)
|
||||
} else {
|
||||
changeNumberRepository.whoAmI()
|
||||
.map { whoAmI ->
|
||||
if (Objects.equals(whoAmI.number, localNumber)) {
|
||||
Log.i(TAG, "Local and remote numbers match, we can unlock.")
|
||||
SignalStore.misc().unlockChangeNumber()
|
||||
}
|
||||
processor
|
||||
}
|
||||
.onErrorReturn { processor }
|
||||
}
|
||||
}
|
||||
|
||||
override fun verifyAccountWithoutRegistrationLock(): Single<ServiceResponse<VerifyAccountResponse>> {
|
||||
return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number)
|
||||
}
|
||||
|
||||
override fun verifyAccountWithRegistrationLock(pin: String, kbsTokenData: TokenData): Single<ServiceResponse<VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse>> {
|
||||
return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number, pin, kbsTokenData)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onVerifySuccess(processor: VerifyAccountResponseProcessor): Single<VerifyAccountResponseProcessor> {
|
||||
return changeNumberRepository.changeLocalNumber(number.e164Number)
|
||||
.map { processor }
|
||||
.onErrorReturn { t ->
|
||||
Log.w(TAG, "Error attempting to change local number", t)
|
||||
VerifyAccountResponseWithoutKbs(ServiceResponse.forUnknownError(t))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVerifySuccessWithRegistrationLock(processor: VerifyCodeWithRegistrationLockResponseProcessor, pin: String): Single<VerifyCodeWithRegistrationLockResponseProcessor> {
|
||||
return changeNumberRepository.changeLocalNumber(number.e164Number)
|
||||
.map { processor }
|
||||
.onErrorReturn { t ->
|
||||
Log.w(TAG, "Error attempting to change local number", t)
|
||||
VerifyCodeWithRegistrationLockResponseProcessor(ServiceResponse.forUnknownError(t), processor.token)
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(owner: SavedStateRegistryOwner) : AbstractSavedStateViewModelFactory(owner, null) {
|
||||
|
||||
override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
|
||||
val context: Application = ApplicationDependencies.getApplication()
|
||||
val localNumber: String = TextSecurePreferences.getLocalNumber(context)
|
||||
val password: String = TextSecurePreferences.getPushServerPassword(context)
|
||||
|
||||
val viewModel = ChangeNumberViewModel(
|
||||
localNumber = localNumber,
|
||||
changeNumberRepository = ChangeNumberRepository(context),
|
||||
savedState = handle,
|
||||
password = password,
|
||||
verifyAccountRepository = VerifyAccountRepository(context),
|
||||
kbsRepository = KbsRepository()
|
||||
)
|
||||
|
||||
return requireNotNull(modelClass.cast(viewModel))
|
||||
}
|
||||
}
|
||||
|
||||
enum class ContinueStatus {
|
||||
CAN_CONTINUE,
|
||||
INVALID_NUMBER,
|
||||
OLD_NUMBER_DOESNT_MATCH
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
@@ -21,7 +22,7 @@ class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) :
|
||||
useAddressBook = SignalStore.settings().isPreferSystemContactPhotos,
|
||||
useSystemEmoji = SignalStore.settings().isPreferSystemEmoji,
|
||||
enterKeySends = SignalStore.settings().isEnterKeySends,
|
||||
chatBackupsEnabled = SignalStore.settings().isBackupEnabled
|
||||
chatBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
|
||||
@@ -73,8 +74,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_values),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_values_description),
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_config),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_config_description),
|
||||
onClick = {
|
||||
refreshRemoteValues()
|
||||
}
|
||||
@@ -82,7 +83,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_display)
|
||||
sectionHeaderPref(R.string.preferences__internal_misc)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_user_details),
|
||||
@@ -93,6 +94,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_shake_to_report),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_shake_to_report_description),
|
||||
isChecked = state.shakeToReport,
|
||||
onClick = {
|
||||
viewModel.setShakeToReport(!state.shakeToReport)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_storage_service)
|
||||
@@ -255,6 +265,18 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_local_metrics)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_clear_local_metrics),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_click_to_clear_all_local_metrics_state),
|
||||
onClick = {
|
||||
clearAllLocalMetricsState()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_calling)
|
||||
|
||||
radioPref(
|
||||
@@ -354,4 +376,9 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
DatabaseFactory.getSenderKeySharedDatabase(requireContext()).deleteAll()
|
||||
Toast.makeText(context, "Deleted all sender key shared state.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun clearAllLocalMetricsState() {
|
||||
LocalMetricsDatabase.getInstance(ApplicationDependencies.getApplication()).clear()
|
||||
Toast.makeText(context, "Cleared all local metrics state.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.thoughtcrime.securesms.emoji.EmojiFiles
|
||||
|
||||
data class InternalSettingsState(
|
||||
val seeMoreUserDetails: Boolean,
|
||||
val shakeToReport: Boolean,
|
||||
val gv2doNotCreateGv2Groups: Boolean,
|
||||
val gv2forceInvites: Boolean,
|
||||
val gv2ignoreServerChanges: Boolean,
|
||||
|
||||
@@ -25,6 +25,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setShakeToReport(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.SHAKE_TO_REPORT, enabled)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setGv2DoNotCreateGv2Groups(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.GV2_DO_NOT_CREATE_GV2, enabled)
|
||||
refresh()
|
||||
@@ -86,6 +91,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
|
||||
private fun getState() = InternalSettingsState(
|
||||
seeMoreUserDetails = SignalStore.internalValues().recipientDetails(),
|
||||
shakeToReport = SignalStore.internalValues().shakeToReport(),
|
||||
gv2doNotCreateGv2Groups = SignalStore.internalValues().gv2DoNotCreateGv2Groups(),
|
||||
gv2forceInvites = SignalStore.internalValues().gv2ForceInvites(),
|
||||
gv2ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges(),
|
||||
|
||||
@@ -38,7 +38,7 @@ class ExpireTimerSettingsRepository(val context: Context) {
|
||||
} else {
|
||||
DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipientId, newExpirationTime)
|
||||
val outgoingMessage = OutgoingExpirationUpdateMessage(Recipient.resolved(recipientId), System.currentTimeMillis(), newExpirationTime * 1000L)
|
||||
MessageSender.send(context, outgoingMessage, getThreadId(recipientId), false, null)
|
||||
MessageSender.send(context, outgoingMessage, getThreadId(recipientId), false, null, null)
|
||||
consumer.invoke(Result.success(newExpirationTime))
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,6 @@ class ExpireTimerSettingsRepository(val context: Context) {
|
||||
private fun getThreadId(recipientId: RecipientId): Long {
|
||||
val threadDatabase: ThreadDatabase = DatabaseFactory.getThreadDatabase(context)
|
||||
val recipient: Recipient = Recipient.resolved(recipientId)
|
||||
return threadDatabase.getThreadIdFor(recipient)
|
||||
return threadDatabase.getOrCreateThreadIdFor(recipient)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class ExpireTimerSettingsViewModel(val config: Config, private val repository: E
|
||||
|
||||
init {
|
||||
if (recipientId != null) {
|
||||
store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(initialTimer = r.expireMessages, isForRecipient = true) }
|
||||
store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(initialTimer = r.expiresInSeconds, isForRecipient = true) }
|
||||
} else {
|
||||
store.update { it.copy(initialTimer = config.initialValue ?: SignalStore.settings().universalExpireTimer) }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ sealed class ConversationSettingsEvent {
|
||||
val groupId: GroupId,
|
||||
val selectionWarning: Int,
|
||||
val selectionLimit: Int,
|
||||
val isAnnouncementGroup: Boolean,
|
||||
val groupMembersWithoutSelf: List<RecipientId>
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
|
||||
@@ -237,11 +237,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
AvatarPreference.Model(
|
||||
recipient = state.recipient,
|
||||
onAvatarClick = { avatar ->
|
||||
requireActivity().apply {
|
||||
startActivity(
|
||||
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
|
||||
AvatarPreviewActivity.createTransitionBundle(this, avatar)
|
||||
)
|
||||
if (!state.recipient.isSelf) {
|
||||
requireActivity().apply {
|
||||
startActivity(
|
||||
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
|
||||
AvatarPreviewActivity.createTransitionBundle(this, avatar)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -315,7 +317,15 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
ButtonStripPreference.Model(
|
||||
state = state.buttonStripState,
|
||||
onVideoClick = {
|
||||
CommunicationActions.startVideoCall(requireActivity(), state.recipient)
|
||||
if (state.recipient.isPushV2Group && state.requireGroupSettingsState().isAnnouncementGroup && !state.requireGroupSettingsState().isSelfAdmin) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.ConversationActivity_cant_start_group_call)
|
||||
.setMessage(R.string.ConversationActivity_only_admins_of_this_group_can_start_a_call)
|
||||
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
|
||||
.show()
|
||||
} else {
|
||||
CommunicationActions.startVideoCall(requireActivity(), state.recipient)
|
||||
}
|
||||
},
|
||||
onAudioClick = {
|
||||
CommunicationActions.startVoiceCall(requireActivity(), state.recipient)
|
||||
@@ -664,6 +674,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
ContactsCursorLoader.DisplayMode.FLAG_PUSH,
|
||||
addMembersToGroup.selectionWarning,
|
||||
addMembersToGroup.selectionLimit,
|
||||
addMembersToGroup.isAnnouncementGroup,
|
||||
addMembersToGroup.groupMembersWithoutSelf
|
||||
),
|
||||
REQUEST_CODE_ADD_MEMBERS_TO_GROUP
|
||||
|
||||
@@ -11,13 +11,15 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupManager
|
||||
import org.thoughtcrime.securesms.groups.GroupProtoUtil
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -26,6 +28,7 @@ import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.libsignal.util.guava.Preconditions
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val TAG = Log.tag(ConversationSettingsRepository::class.java)
|
||||
|
||||
@@ -61,13 +64,9 @@ class ConversationSettingsRepository(
|
||||
SignalExecutors.BOUNDED.execute { consumer(DatabaseFactory.getGroupDatabase(context).activeGroupCount > 0) }
|
||||
}
|
||||
|
||||
fun getIdentity(recipientId: RecipientId, consumer: (IdentityDatabase.IdentityRecord?) -> Unit) {
|
||||
fun getIdentity(recipientId: RecipientId, consumer: (IdentityRecord?) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(
|
||||
DatabaseFactory.getIdentityDatabase(context)
|
||||
.getIdentity(recipientId)
|
||||
.orNull()
|
||||
)
|
||||
consumer(ApplicationDependencies.getIdentityStore().getIdentityRecord(recipientId).orNull())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +129,9 @@ class ConversationSettingsRepository(
|
||||
members.addAll(groupRecord.members)
|
||||
members.addAll(pendingMembers)
|
||||
|
||||
GroupCapacityResult(Recipient.self().id, members, FeatureFlags.groupLimits())
|
||||
GroupCapacityResult(Recipient.self().id, members, FeatureFlags.groupLimits(), groupRecord.isAnnouncementGroup)
|
||||
} else {
|
||||
GroupCapacityResult(Recipient.self().id, groupRecord.members, FeatureFlags.groupLimits())
|
||||
GroupCapacityResult(Recipient.self().id, groupRecord.members, FeatureFlags.groupLimits(), false)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -140,6 +139,25 @@ class ConversationSettingsRepository(
|
||||
|
||||
fun addMembers(groupId: GroupId, selected: List<RecipientId>, consumer: (GroupAddMembersResult) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val record: GroupDatabase.GroupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get()
|
||||
|
||||
if (record.isAnnouncementGroup) {
|
||||
val needsResolve = selected
|
||||
.map { Recipient.resolved(it) }
|
||||
.filter { it.announcementGroupCapability != Recipient.Capability.SUPPORTED && !it.isSelf }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
|
||||
ApplicationDependencies.getJobManager().runSynchronously(RetrieveProfileJob(needsResolve), TimeUnit.SECONDS.toMillis(10))
|
||||
|
||||
val updatedWithCapabilities = needsResolve.map { Recipient.resolved(it) }
|
||||
|
||||
if (updatedWithCapabilities.any { it.announcementGroupCapability != Recipient.Capability.SUPPORTED }) {
|
||||
consumer(GroupAddMembersResult.Failure(GroupChangeFailureReason.NOT_ANNOUNCEMENT_CAPABLE))
|
||||
return@execute
|
||||
}
|
||||
}
|
||||
|
||||
consumer(
|
||||
try {
|
||||
val groupActionResult = GroupManager.addMembers(context, groupId.requirePush(), selected)
|
||||
@@ -198,7 +216,14 @@ class ConversationSettingsRepository(
|
||||
Preconditions.checkArgument(FeatureFlags.internalUser(), "Internal users only!")
|
||||
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipientId)
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
|
||||
if (recipient.hasUuid()) {
|
||||
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireUuid().toString())
|
||||
}
|
||||
if (recipient.hasE164()) {
|
||||
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireE164())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.components.settings.conversation
|
||||
import android.database.Cursor
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -43,7 +43,7 @@ sealed class SpecificSettingsState {
|
||||
abstract val isLoaded: Boolean
|
||||
|
||||
data class RecipientSettingsState(
|
||||
val identityRecord: IdentityDatabase.IdentityRecord? = null,
|
||||
val identityRecord: IdentityRecord? = null,
|
||||
val allGroupsInCommon: List<Recipient> = listOf(),
|
||||
val groupsInCommon: List<Recipient> = listOf(),
|
||||
val selfHasGroups: Boolean = false,
|
||||
@@ -75,7 +75,8 @@ sealed class SpecificSettingsState {
|
||||
private val groupDescriptionLoaded: Boolean = false,
|
||||
val groupLinkEnabled: Boolean = false,
|
||||
val membershipCountDescription: String = "",
|
||||
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE
|
||||
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE,
|
||||
val isAnnouncementGroup: Boolean = false
|
||||
) : SpecificSettingsState() {
|
||||
|
||||
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded
|
||||
|
||||
@@ -144,7 +144,7 @@ sealed class ConversationSettingsViewModel(
|
||||
isMuteAvailable = !recipient.isSelf,
|
||||
isSearchAvailable = true
|
||||
),
|
||||
disappearingMessagesLifespan = recipient.expireMessages,
|
||||
disappearingMessagesLifespan = recipient.expiresInSeconds,
|
||||
canModifyBlockedState = !recipient.isSelf,
|
||||
specificSettingsState = state.requireRecipientSettingsState().copy(
|
||||
contactLinkState = when {
|
||||
@@ -322,6 +322,14 @@ sealed class ConversationSettingsViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
store.update(liveGroup.isAnnouncementGroup) { announcementGroup, state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
isAnnouncementGroup = announcementGroup
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val isMessageRequestAccepted: LiveData<Boolean> = LiveDataUtil.mapAsync(liveGroup.groupRecipient) { r -> repository.isMessageRequestAccepted(r) }
|
||||
val descriptionState: LiveData<DescriptionState> = LiveDataUtil.combineLatest(liveGroup.description, isMessageRequestAccepted, ::DescriptionState)
|
||||
|
||||
@@ -386,11 +394,13 @@ sealed class ConversationSettingsViewModel(
|
||||
override fun onAddToGroup() {
|
||||
repository.getGroupCapacity(groupId) { capacityResult ->
|
||||
if (capacityResult.getRemainingCapacity() > 0) {
|
||||
|
||||
internalEvents.postValue(
|
||||
ConversationSettingsEvent.AddMembersToGroup(
|
||||
groupId,
|
||||
capacityResult.getSelectionWarning(),
|
||||
capacityResult.getSelectionLimit(),
|
||||
capacityResult.isAnnouncementGroup,
|
||||
capacityResult.getMembersWithoutSelf()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
class GroupCapacityResult(
|
||||
private val selfId: RecipientId,
|
||||
private val members: List<RecipientId>,
|
||||
private val selectionLimits: SelectionLimits
|
||||
private val selectionLimits: SelectionLimits,
|
||||
val isAnnouncementGroup: Boolean
|
||||
) {
|
||||
fun getMembers(): List<RecipientId?> {
|
||||
return members
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user