mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-17 16:36:06 +00:00
Compare commits
354 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf40a07bb9 | ||
|
|
8f3a6b8479 | ||
|
|
7642b7cc72 | ||
|
|
e12ea60d85 | ||
|
|
0b13c4aed6 | ||
|
|
47919382e9 | ||
|
|
d60d67ee7e | ||
|
|
559aa687a5 | ||
|
|
bc0761f002 | ||
|
|
c0c2fc0eba | ||
|
|
44fe43c74c | ||
|
|
53a2a5d693 | ||
|
|
2334c26cbb | ||
|
|
0b6dde46d9 | ||
|
|
98d9d81aff | ||
|
|
736a62b632 | ||
|
|
cea6a83d8a | ||
|
|
2751fd7efc | ||
|
|
2822042eeb | ||
|
|
dc46d88ddd | ||
|
|
e04f76b558 | ||
|
|
a758056494 | ||
|
|
1ecdea5db3 | ||
|
|
e1bb773d85 | ||
|
|
7e934eff5d | ||
|
|
cfdf5603af | ||
|
|
45bfb8c6b6 | ||
|
|
65608a51b8 | ||
|
|
b6314597fe | ||
|
|
20a588199a | ||
|
|
59916f1e95 | ||
|
|
8b91f8f9e7 | ||
|
|
cbc3cce66f | ||
|
|
b4b63b5860 | ||
|
|
b9ae15a890 | ||
|
|
d955389c46 | ||
|
|
975eb885c1 | ||
|
|
a3aed96757 | ||
|
|
dc70bfabaf | ||
|
|
6932340671 | ||
|
|
f6637b7caf | ||
|
|
4f4be44caa | ||
|
|
7832497ba7 | ||
|
|
7d06e2395f | ||
|
|
3a479d7eef | ||
|
|
8fe8a1e9ee | ||
|
|
2d8b2e7fb0 | ||
|
|
9c0365f92c | ||
|
|
b48abb08d2 | ||
|
|
d8f3e032c7 | ||
|
|
8dbcb255ad | ||
|
|
db06cbbc86 | ||
|
|
98ab23c1a3 | ||
|
|
d0ca9ba6a6 | ||
|
|
b242368675 | ||
|
|
664527ce63 | ||
|
|
99e4f80be0 | ||
|
|
702dae9fcd | ||
|
|
48fe1ba559 | ||
|
|
382ac7ba0d | ||
|
|
a46f47f352 | ||
|
|
e984d8a42c | ||
|
|
554bad6b8d | ||
|
|
ed13c97ad7 | ||
|
|
d33873d59a | ||
|
|
1234899ea1 | ||
|
|
13027dc44b | ||
|
|
5b4d74b7fe | ||
|
|
18c7bc2b5b | ||
|
|
bbbee0f372 | ||
|
|
cf9d090154 | ||
|
|
718471917f | ||
|
|
bb97407cde | ||
|
|
92ce678e29 | ||
|
|
e100aea2c7 | ||
|
|
fea3b6cb4a | ||
|
|
afbc132faa | ||
|
|
b27198286d | ||
|
|
ac93d81032 | ||
|
|
9981e5ca76 | ||
|
|
7dd3efeb53 | ||
|
|
d38d702adf | ||
|
|
04a000a8a8 | ||
|
|
3bbf0741ee | ||
|
|
e9a336100b | ||
|
|
fb600e9829 | ||
|
|
4a455ff958 | ||
|
|
707e238e5c | ||
|
|
90f22a4b66 | ||
|
|
b4f134adf7 | ||
|
|
1e00fc6149 | ||
|
|
f52133a69c | ||
|
|
91b142e0d9 | ||
|
|
26a9dd98c1 | ||
|
|
99e38e1d23 | ||
|
|
a2d8a25fd9 | ||
|
|
d86d625bcc | ||
|
|
18e3fb6609 | ||
|
|
da33ba0ed5 | ||
|
|
66f021d01a | ||
|
|
40231ea45f | ||
|
|
cd80a47c04 | ||
|
|
1033bd7bda | ||
|
|
b4f60f3acb | ||
|
|
bed3b571cc | ||
|
|
c8dd4e5254 | ||
|
|
514048171b | ||
|
|
32e9901592 | ||
|
|
d83f86a469 | ||
|
|
403d53586c | ||
|
|
6acae58694 | ||
|
|
a3f9737e63 | ||
|
|
263af7c139 | ||
|
|
7f2439f1e9 | ||
|
|
ae87d23003 | ||
|
|
3192cc0aac | ||
|
|
6102e9aa72 | ||
|
|
f4a152b0fe | ||
|
|
2b11bca7dc | ||
|
|
07d19f38e3 | ||
|
|
cd228c439e | ||
|
|
7a859c8961 | ||
|
|
543f38c75d | ||
|
|
f7b150f2d2 | ||
|
|
11328f643f | ||
|
|
f270a6b8c4 | ||
|
|
3fec23fd36 | ||
|
|
e01838e996 | ||
|
|
f70e41e7cd | ||
|
|
c4ec0c9897 | ||
|
|
989b071a6d | ||
|
|
c39751f9db | ||
|
|
dbf74a2234 | ||
|
|
837230d72d | ||
|
|
f544ec4126 | ||
|
|
79dbf85c1e | ||
|
|
61fe6cc961 | ||
|
|
70c88b68e2 | ||
|
|
d70c33d20f | ||
|
|
6b2e000e61 | ||
|
|
b9f11dafff | ||
|
|
9b32eaeb8a | ||
|
|
a99c0d438e | ||
|
|
c634c24afb | ||
|
|
2ddd1437cf | ||
|
|
9da309ca48 | ||
|
|
cfcd451db7 | ||
|
|
5ab72fd1a9 | ||
|
|
daace9bd1a | ||
|
|
69adcd1d69 | ||
|
|
0711a22188 | ||
|
|
3a06412cd8 | ||
|
|
51c82702e2 | ||
|
|
1b01196ec6 | ||
|
|
1cd6b58ece | ||
|
|
ea8e13b1c8 | ||
|
|
f392229393 | ||
|
|
a299bafe89 | ||
|
|
d2bf539504 | ||
|
|
903c3989b9 | ||
|
|
00996f0d7a | ||
|
|
4aded3a436 | ||
|
|
9acdc37729 | ||
|
|
d4cdcbe54f | ||
|
|
6fa2a0f411 | ||
|
|
558a8e4a14 | ||
|
|
8947b82034 | ||
|
|
56551025e9 | ||
|
|
befb4939d5 | ||
|
|
289f7aba63 | ||
|
|
28bd245b96 | ||
|
|
c5e7300df2 | ||
|
|
fe25d941bb | ||
|
|
4cda267f3b | ||
|
|
82ba7e2b8b | ||
|
|
41ebaf3938 | ||
|
|
090c400037 | ||
|
|
12b1232ac0 | ||
|
|
204a84c522 | ||
|
|
526afd539b | ||
|
|
d708984abd | ||
|
|
9d39db6428 | ||
|
|
67a8ec0d39 | ||
|
|
297a7d0ef8 | ||
|
|
4712833853 | ||
|
|
11d17f7496 | ||
|
|
36df3f234f | ||
|
|
098b298646 | ||
|
|
2f9320989a | ||
|
|
ec8d5defd4 | ||
|
|
981676c7f8 | ||
|
|
7c5ae57784 | ||
|
|
fc7be87468 | ||
|
|
e55d8007fc | ||
|
|
43b7aa2d52 | ||
|
|
cd1bad0718 | ||
|
|
6b47618351 | ||
|
|
b6d384120d | ||
|
|
1268b26c1f | ||
|
|
f1233bfddc | ||
|
|
1aa3e6afea | ||
|
|
ce21eb241a | ||
|
|
f96fb72eb1 | ||
|
|
207c467c6b | ||
|
|
9d1d9e33ed | ||
|
|
e4a76c0690 | ||
|
|
124c3e25e9 | ||
|
|
5cb1201903 | ||
|
|
bb6ca80d5a | ||
|
|
dc7c54a1f8 | ||
|
|
23401440bf | ||
|
|
f8f959e05a | ||
|
|
edbd4d2d03 | ||
|
|
a0b4065be3 | ||
|
|
1b2f964f32 | ||
|
|
eaf5280d99 | ||
|
|
d435da980f | ||
|
|
8d3a91f3a4 | ||
|
|
b80c339c5a | ||
|
|
34159fc9da | ||
|
|
b509ee9ee0 | ||
|
|
a6819448b9 | ||
|
|
f2847f9aa5 | ||
|
|
8f01e5e1c3 | ||
|
|
acb2f43620 | ||
|
|
62ac65e4d8 | ||
|
|
8f183bdcdc | ||
|
|
3d135d155e | ||
|
|
090c811391 | ||
|
|
2a9e8dc525 | ||
|
|
cb0b22cf2c | ||
|
|
5aba3517ce | ||
|
|
726f665388 | ||
|
|
e2ac55e9ac | ||
|
|
fa5729bac6 | ||
|
|
e714cb6423 | ||
|
|
35a0162d5c | ||
|
|
76740adc3f | ||
|
|
1c814141a2 | ||
|
|
5545daf992 | ||
|
|
d300615d90 | ||
|
|
908a5260c2 | ||
|
|
7aac6644c3 | ||
|
|
3b673c07a0 | ||
|
|
d726da822c | ||
|
|
7894f72b0f | ||
|
|
4c5822ac67 | ||
|
|
b917cccbee | ||
|
|
01d2d05d8e | ||
|
|
4de86cb6cf | ||
|
|
8861ad76ed | ||
|
|
ef86372635 | ||
|
|
ccff7b1148 | ||
|
|
ed0825112d | ||
|
|
b8df90531f | ||
|
|
f099c3591c | ||
|
|
ed33e048ad | ||
|
|
7fd3bfa30c | ||
|
|
07a492a32c | ||
|
|
11fffbd79e | ||
|
|
eff564ad88 | ||
|
|
d3d53e6099 | ||
|
|
53d122ed55 | ||
|
|
1778c1ef7d | ||
|
|
a510bc74e6 | ||
|
|
a9ecdbdfec | ||
|
|
06ab3cf013 | ||
|
|
3db5da1c8d | ||
|
|
5937a50b6d | ||
|
|
b4191ee5cc | ||
|
|
c63e42715e | ||
|
|
26e582d806 | ||
|
|
ee9270845a | ||
|
|
6cf33897c0 | ||
|
|
2161bbb8fa | ||
|
|
b75088874e | ||
|
|
9ac1897880 | ||
|
|
36c43ed2fa | ||
|
|
8084822f16 | ||
|
|
959718618f | ||
|
|
75f3fe0cec | ||
|
|
b800477365 | ||
|
|
b191341c57 | ||
|
|
88a40be901 | ||
|
|
3fef58057e | ||
|
|
b156e4a79a | ||
|
|
30ac264cd3 | ||
|
|
a9b00e1cd3 | ||
|
|
d94fc4bc13 | ||
|
|
40b5339ef8 | ||
|
|
86f0456e8c | ||
|
|
48a693793f | ||
|
|
ff28d72db6 | ||
|
|
456857bbbd | ||
|
|
7f17b66a6c | ||
|
|
310ec8f296 | ||
|
|
0c2afa9438 | ||
|
|
c3832cf8b1 | ||
|
|
a2de8a2a05 | ||
|
|
3b601896d2 | ||
|
|
e1a90bcb00 | ||
|
|
2b65916344 | ||
|
|
f149005026 | ||
|
|
5eb663aa1b | ||
|
|
12b7d6c0e3 | ||
|
|
723639d928 | ||
|
|
e0502c24e1 | ||
|
|
358d6333c3 | ||
|
|
0b279d1df3 | ||
|
|
8e0fba7992 | ||
|
|
d5419ec9fa | ||
|
|
33e3f78be6 | ||
|
|
3c5ad519dd | ||
|
|
17c5b858b5 | ||
|
|
f6f6496c9c | ||
|
|
b1d725e23a | ||
|
|
a74622997e | ||
|
|
b1a200001e | ||
|
|
3b1041fa1f | ||
|
|
a83ccc18bb | ||
|
|
618b1b5ace | ||
|
|
14858adc88 | ||
|
|
c07f35f3aa | ||
|
|
87eab27996 | ||
|
|
b7296a4fe3 | ||
|
|
3fb9ae1fb4 | ||
|
|
9705939489 | ||
|
|
eca67b1204 | ||
|
|
c59fc3581a | ||
|
|
e00f8c94ff | ||
|
|
4186153f0c | ||
|
|
6c01807f4f | ||
|
|
9d35fb397b | ||
|
|
c9f2f57427 | ||
|
|
c862ab0c56 | ||
|
|
7aaaa57c14 | ||
|
|
11b6394a87 | ||
|
|
bdd48fd2df | ||
|
|
e99af75400 | ||
|
|
321440e13f | ||
|
|
0556d984e0 | ||
|
|
0ba1f66136 | ||
|
|
7562555687 | ||
|
|
668ccfcd12 | ||
|
|
9c0337c4ef | ||
|
|
3fde06ab0f | ||
|
|
73959f328a | ||
|
|
cca85bfee3 | ||
|
|
575caa53d3 | ||
|
|
33874a8866 | ||
|
|
b8e909a134 | ||
|
|
5193a5d309 | ||
|
|
7db288b9aa | ||
|
|
9f033e64aa |
6
.github/workflows/android.yml
vendored
6
.github/workflows/android.yml
vendored
@@ -14,11 +14,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: set up JDK 1.8
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 1.8
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | sudo /usr/local/lib/android/sdk/tools/bin/sdkmanager --install "ndk;20.0.5594570" --sdk_root=${ANDROID_SDK_ROOT}
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew qa
|
||||
|
||||
@@ -59,9 +59,7 @@ The form and manner of this distribution makes it eligible for export under the
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2011 Whisper Systems
|
||||
|
||||
Copyright 2013-2020 Open Whisper Systems
|
||||
Copyright 2013-2020 Signal
|
||||
|
||||
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ buildscript {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.5.1'
|
||||
classpath 'com.android.tools.build:gradle:3.6.3'
|
||||
classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.1.0'
|
||||
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10'
|
||||
}
|
||||
@@ -80,8 +80,8 @@ protobuf {
|
||||
}
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 622
|
||||
def canonicalVersionName = "4.59.2"
|
||||
def canonicalVersionCode = 655
|
||||
def canonicalVersionName = "4.63.2"
|
||||
|
||||
def postFixSize = 10
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -126,7 +126,7 @@ android {
|
||||
buildConfigField "String", "KBS_ENCLAVE_NAME", "\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\""
|
||||
buildConfigField "String", "KBS_MRENCLAVE", "\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\""
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
|
||||
@@ -161,7 +161,6 @@ android {
|
||||
exclude 'META-INF/LICENSE'
|
||||
exclude 'META-INF/NOTICE'
|
||||
exclude 'META-INF/proguard/androidx-annotations.pro'
|
||||
exclude 'lib/*/libzkgroup.so' // TODO: GV2 Remove line to include .so when used
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -201,7 +200,7 @@ android {
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"ba4ebb438bc07713819ee6c98d94037747006d7df63fc9e44d2d6f1fec962a79\""
|
||||
buildConfigField "String", "KBS_ENCLAVE_NAME", "\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\""
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
|
||||
}
|
||||
flipper {
|
||||
initWith debug
|
||||
@@ -255,9 +254,13 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.1.0-beta01'
|
||||
lintChecks project(':lintchecks')
|
||||
|
||||
implementation('androidx.appcompat:appcompat:1.1.0-beta01') {
|
||||
force = true
|
||||
}
|
||||
implementation 'androidx.recyclerview:recyclerview:1.0.0'
|
||||
implementation 'com.google.android.material:material:1.0.0'
|
||||
implementation 'com.google.android.material:material:1.1.0'
|
||||
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.preference:preference:1.0.0'
|
||||
@@ -275,8 +278,13 @@ dependencies {
|
||||
implementation "androidx.camera:camera-camera2:1.0.0-beta01"
|
||||
implementation "androidx.camera:camera-lifecycle:1.0.0-beta01"
|
||||
implementation "androidx.concurrent:concurrent-futures:1.0.0"
|
||||
implementation "androidx.autofill:autofill:1.0.0"
|
||||
implementation "androidx.paging:paging-common:2.1.2"
|
||||
implementation "androidx.paging:paging-runtime:2.1.2"
|
||||
implementation 'com.google.firebase:firebase-ml-vision:24.0.3'
|
||||
implementation 'com.google.firebase:firebase-ml-vision-face-model:20.0.1'
|
||||
|
||||
implementation('com.google.firebase:firebase-messaging:17.3.4') {
|
||||
implementation ('com.google.firebase:firebase-messaging:20.2.0') {
|
||||
exclude group: 'com.google.firebase', module: 'firebase-core'
|
||||
exclude group: 'com.google.firebase', module: 'firebase-analytics'
|
||||
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
|
||||
@@ -292,11 +300,11 @@ dependencies {
|
||||
implementation 'org.signal:aesgcmprovider:0.0.3'
|
||||
|
||||
implementation project(':libsignal-service')
|
||||
implementation 'org.signal:zkgroup-android:0.4.1'
|
||||
implementation 'org.signal:zkgroup-android:0.7.0'
|
||||
|
||||
implementation 'org.signal:argon2:13.1@aar'
|
||||
|
||||
implementation 'org.signal:ringrtc-android:1.3.1'
|
||||
implementation 'org.signal:ringrtc-android:2.0.3'
|
||||
|
||||
implementation "me.leolin:ShortcutBadger:1.1.16"
|
||||
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
|
||||
|
||||
@@ -17,9 +17,17 @@
|
||||
<issue id="ButtonOrder" severity="error" />
|
||||
<issue id="ExtraTranslation" severity="warning" />
|
||||
|
||||
<!-- Custom lints -->
|
||||
<issue id="LogNotSignal" severity="error" />
|
||||
<issue id="LogNotAppSignal" severity="error" />
|
||||
<issue id="LogTagInlined" severity="error" />
|
||||
|
||||
<issue id="RestrictedApi" severity="error">
|
||||
<ignore path="*/org/thoughtcrime/securesms/mediasend/camerax/VideoCapture.java" />
|
||||
<ignore path="*/org/thoughtcrime/securesms/mediasend/camerax/CameraXModule.java" />
|
||||
<ignore path="*/org/thoughtcrime/securesms/conversation/*.java" />
|
||||
<ignore path="*/org/thoughtcrime/securesms/lock/v2/CreateKbsPinViewModel.java" />
|
||||
<ignore path="*/org/thoughtcrime/securesms/jobs/StickerPackDownloadJob.java" />
|
||||
</issue>
|
||||
|
||||
</lint>
|
||||
|
||||
29
app/sampledata/contacts.json
Normal file
29
app/sampledata/contacts.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"name": "Ottttooooooooo Ocataaaaaaaavius",
|
||||
"number": "+1 (555) 555-5555",
|
||||
"label": "Mobile"
|
||||
},
|
||||
{
|
||||
"name": "Victor Von Doom Phd",
|
||||
"number": "+1 (555) 123-4567",
|
||||
"label": "Home"
|
||||
},
|
||||
{
|
||||
"name": "Flash Thompson",
|
||||
"number": "+1 (555) 435-1261",
|
||||
"label": "Work"
|
||||
},
|
||||
{
|
||||
"name": "Dr. Curtis Connors",
|
||||
"number": "+1 (555) 992-1567",
|
||||
"label": "Mobile"
|
||||
},
|
||||
{
|
||||
"name": "Billy Russo",
|
||||
"number": "+1 (555) 234-1516",
|
||||
"label": "Mobile"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -117,7 +117,9 @@
|
||||
android:theme="@style/TextSecure.LightTheme.WebRTCCall"
|
||||
android:excludeFromRecents="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|fontScale"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
||||
android:launchMode="singleTask"/>
|
||||
|
||||
<activity android:name=".InviteActivity"
|
||||
@@ -255,6 +257,10 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/TextSecure.LightNoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.managegroup.ManageGroupActivity"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".DatabaseMigrationActivity"
|
||||
android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar"
|
||||
android:launchMode="singleTask"
|
||||
@@ -341,12 +347,17 @@
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".MediaPreviewActivity"
|
||||
<activity android:name=".MediaPreviewActivity"
|
||||
android:label="@string/AndroidManifest__media_preview"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".AvatarPreviewActivity"
|
||||
android:label="@string/AndroidManifest__media_preview"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".mediaoverview.MediaOverviewActivity"
|
||||
android:theme="@style/TextSecure.LightNoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
@@ -416,9 +427,8 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".profiles.edit.EditProfileActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:windowSoftInputMode="stateVisible"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize" />
|
||||
|
||||
<activity android:name=".lock.v2.CreateKbsPinActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
@@ -479,11 +489,22 @@
|
||||
android:theme="@style/TextSecure.LightNoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" />
|
||||
|
||||
<activity android:name=".groups.ui.creategroup.CreateGroupActivity"
|
||||
android:theme="@style/TextSecure.LightNoActionBar" />
|
||||
|
||||
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"/>
|
||||
<activity android:name=".groups.ui.addtogroup.AddToGroupsActivity"
|
||||
android:theme="@style/TextSecure.LightNoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.addmembers.AddMembersActivity"
|
||||
android:theme="@style/TextSecure.LightNoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.creategroup.details.AddGroupDetailsActivity"
|
||||
android:theme="@style/TextSecure.LightNoActionBar" />
|
||||
|
||||
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"/>
|
||||
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
|
||||
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
||||
<service android:enabled="true" android:name=".service.IncomingMessageObserver$ForegroundService"/>
|
||||
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
|
||||
|
||||
<service android:name=".service.QuickResponseService"
|
||||
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE"
|
||||
@@ -522,7 +543,9 @@
|
||||
|
||||
<service android:name=".service.GenericForegroundService"/>
|
||||
|
||||
<service android:name=".gcm.FcmService">
|
||||
<service android:name=".gcm.FcmFetchService" />
|
||||
|
||||
<service android:name=".gcm.FcmReceiveService">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
|
||||
public final class AppCapabilities {
|
||||
@@ -7,14 +8,13 @@ public final class AppCapabilities {
|
||||
private AppCapabilities() {
|
||||
}
|
||||
|
||||
private static final boolean UUID_CAPABLE = false;
|
||||
private static final boolean GROUPS_V2_CAPABLE = false;
|
||||
private static final boolean UUID_CAPABLE = false;
|
||||
|
||||
/**
|
||||
* @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 SignalServiceProfile.Capabilities getCapabilities(boolean storageCapable) {
|
||||
return new SignalServiceProfile.Capabilities(UUID_CAPABLE, GROUPS_V2_CAPABLE, storageCapable);
|
||||
return new SignalServiceProfile.Capabilities(UUID_CAPABLE, FeatureFlags.groupsV2(), storageCapable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,15 +44,14 @@ import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
|
||||
import org.thoughtcrime.securesms.logging.AndroidLogger;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler;
|
||||
import org.thoughtcrime.securesms.messages.InitialMessageRetriever;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
@@ -61,7 +60,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
|
||||
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
|
||||
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
@@ -69,7 +68,6 @@ import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
@@ -130,7 +128,6 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
|
||||
initializePendingMessages();
|
||||
initializeBlobProvider();
|
||||
initializeCleanup();
|
||||
initializePlayServicesCheck();
|
||||
|
||||
FeatureFlags.init();
|
||||
NotificationChannels.create(this);
|
||||
@@ -156,6 +153,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
|
||||
KeyCachingService.onAppForegrounded(this);
|
||||
ApplicationDependencies.getFrameRateTracker().begin();
|
||||
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
||||
catchUpOnMessages();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -163,7 +161,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
|
||||
isAppVisible = false;
|
||||
Log.i(TAG, "App is no longer visible.");
|
||||
KeyCachingService.onAppBackgrounded(this);
|
||||
MessageNotifier.setVisibleThread(-1);
|
||||
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
|
||||
ApplicationDependencies.getFrameRateTracker().end();
|
||||
}
|
||||
|
||||
@@ -380,19 +378,34 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
|
||||
});
|
||||
}
|
||||
|
||||
private void initializePlayServicesCheck() {
|
||||
if (TextSecurePreferences.isFcmDisabled(this)) {
|
||||
PlayServicesUtil.PlayServicesStatus status = PlayServicesUtil.getPlayServicesStatus(this);
|
||||
private void catchUpOnMessages() {
|
||||
InitialMessageRetriever retriever = ApplicationDependencies.getInitialMessageRetriever();
|
||||
|
||||
if (status == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
|
||||
Log.i(TAG, "Play Services are newly-available. Updating to use FCM.");
|
||||
|
||||
TextSecurePreferences.setFcmDisabled(this, false);
|
||||
ApplicationDependencies.getJobManager().startChain(new FcmRefreshJob())
|
||||
.then(new RefreshAttributesJob())
|
||||
.enqueue();
|
||||
}
|
||||
if (retriever.isCaughtUp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
switch (retriever.begin(TimeUnit.SECONDS.toMillis(60))) {
|
||||
case SUCCESS:
|
||||
Log.i(TAG, "Successfully caught up on messages. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
case FAILURE_TIMEOUT:
|
||||
Log.w(TAG, "Did not finish catching up due to a timeout. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
case FAILURE_ERROR:
|
||||
Log.w(TAG, "Did not finish catching up due to an error. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
case SKIPPED_ALREADY_CAUGHT_UP:
|
||||
Log.i(TAG, "Already caught up. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
case SKIPPED_ALREADY_RUNNING:
|
||||
Log.i(TAG, "Already in the process of catching up. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.app.ActivityOptionsCompat;
|
||||
|
||||
import com.bumptech.glide.load.DataSource;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.engine.GlideException;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
/**
|
||||
* Activity for displaying avatars full screen.
|
||||
*/
|
||||
public final class AvatarPreviewActivity extends PassphraseRequiredActionBarActivity {
|
||||
|
||||
private static final String TAG = Log.tag(AvatarPreviewActivity.class);
|
||||
|
||||
private static final String RECIPIENT_ID_EXTRA = "recipient_id";
|
||||
|
||||
public static @NonNull Intent intentFromRecipientId(@NonNull Context context,
|
||||
@NonNull RecipientId recipientId)
|
||||
{
|
||||
Intent intent = new Intent(context, AvatarPreviewActivity.class);
|
||||
intent.putExtra(RECIPIENT_ID_EXTRA, recipientId.serialize());
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static Bundle createTransitionBundle(@NonNull Activity activity, @NonNull View from) {
|
||||
return ActivityOptionsCompat.makeSceneTransitionAnimation(activity, from, "avatar").toBundle();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
||||
super.onCreate(savedInstanceState, ready);
|
||||
|
||||
setTheme(R.style.TextSecure_MediaPreview);
|
||||
setContentView(R.layout.contact_photo_preview_activity);
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
ImageView avatar = findViewById(R.id.avatar);
|
||||
|
||||
setSupportActionBar(toolbar);
|
||||
|
||||
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
|
||||
showSystemUI();
|
||||
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
Context context = getApplicationContext();
|
||||
RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA));
|
||||
|
||||
Recipient.live(recipientId).observe(this, recipient -> {
|
||||
ContactPhoto contactPhoto = recipient.isLocalNumber() ? new ProfileContactPhoto(recipient, recipient.getProfileAvatar())
|
||||
: recipient.getContactPhoto();
|
||||
FallbackContactPhoto fallbackPhoto = recipient.isLocalNumber() ? new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_person_large)
|
||||
: recipient.getFallbackContactPhoto();
|
||||
|
||||
GlideApp.with(this).load(contactPhoto)
|
||||
.fallback(fallbackPhoto.asCallCard(this))
|
||||
.error(fallbackPhoto.asCallCard(this))
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.addListener(new RequestListener<Drawable>() {
|
||||
@Override
|
||||
public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
|
||||
Log.w(TAG, "Unable to load avatar, or avatar removed, closing");
|
||||
finish();
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.into(avatar);
|
||||
|
||||
toolbar.setTitle(recipient.toShortString(context));
|
||||
});
|
||||
|
||||
avatar.setOnClickListener(v -> toggleUiVisibility());
|
||||
|
||||
showAndHideWithSystemUI(getWindow(), findViewById(R.id.toolbar_layout));
|
||||
}
|
||||
|
||||
private static void showAndHideWithSystemUI(@NonNull Window window, @NonNull View... views) {
|
||||
window.getDecorView().setOnSystemUiVisibilityChangeListener(visibility -> {
|
||||
boolean hide = (visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0;
|
||||
|
||||
for (View view : views) {
|
||||
view.animate()
|
||||
.alpha(hide ? 0 : 1)
|
||||
.start();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void toggleUiVisibility() {
|
||||
int systemUiVisibility = getWindow().getDecorView().getSystemUiVisibility();
|
||||
if ((systemUiVisibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0) {
|
||||
showSystemUI();
|
||||
} else {
|
||||
hideSystemUI();
|
||||
}
|
||||
}
|
||||
|
||||
private void hideSystemUI() {
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE |
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN );
|
||||
}
|
||||
|
||||
private void showSystemUI() {
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN );
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSupportNavigateUp() {
|
||||
onBackPressed();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -45,5 +46,6 @@ public interface BindableConversationItem extends Unbindable {
|
||||
void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
|
||||
void onInviteSharedContactClicked(@NonNull List<Recipient> choices);
|
||||
void onReactionClicked(long messageId, boolean isMms);
|
||||
void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import java.util.Set;
|
||||
|
||||
public interface BindableConversationListItem extends Unbindable {
|
||||
|
||||
public void bind(@NonNull ThreadRecord thread,
|
||||
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
|
||||
@NonNull Set<Long> typingThreads,
|
||||
@NonNull Set<Long> selectedThreads, boolean batchMode);
|
||||
void bind(@NonNull ThreadRecord thread,
|
||||
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
|
||||
@NonNull Set<Long> typingThreads,
|
||||
@NonNull Set<Long> selectedThreads, boolean batchMode);
|
||||
}
|
||||
|
||||
@@ -3,24 +3,47 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.ContextThemeWrapper;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
public class ClearProfileAvatarActivity extends Activity {
|
||||
|
||||
private static final String ARG_TITLE = "arg_title";
|
||||
|
||||
public static Intent createForUserProfilePhoto() {
|
||||
return new Intent("org.thoughtcrime.securesms.action.CLEAR_PROFILE_PHOTO");
|
||||
}
|
||||
|
||||
public static Intent createForGroupProfilePhoto() {
|
||||
Intent intent = new Intent("org.thoughtcrime.securesms.action.CLEAR_PROFILE_PHOTO");
|
||||
intent.putExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_group_photo);
|
||||
return intent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(R.string.ClearProfileActivity_remove_profile_photo)
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> finish())
|
||||
.setPositiveButton(R.string.ClearProfileActivity_remove, (dialog, which) -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra("delete", true);
|
||||
setResult(Activity.RESULT_OK, result);
|
||||
finish();
|
||||
})
|
||||
.show();
|
||||
int titleId = getIntent().getIntExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_profile_photo);
|
||||
|
||||
new AlertDialog.Builder(new ContextThemeWrapper(this, DynamicTheme.isDarkTheme(this) ? R.style.TextSecure_DarkTheme : R.style.TextSecure_LightTheme))
|
||||
.setMessage(titleId)
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> finish())
|
||||
.setPositiveButton(R.string.ClearProfileActivity_remove, (dialog, which) -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra("delete", true);
|
||||
setResult(Activity.RESULT_OK, result);
|
||||
finish();
|
||||
})
|
||||
.setOnCancelListener(dialog -> finish())
|
||||
.show();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,20 +19,18 @@ package org.thoughtcrime.securesms;
|
||||
import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -46,12 +44,14 @@ import java.lang.ref.WeakReference;
|
||||
*/
|
||||
public abstract class ContactSelectionActivity extends PassphraseRequiredActionBarActivity
|
||||
implements SwipeRefreshLayout.OnRefreshListener,
|
||||
ContactSelectionListFragment.OnContactSelectedListener
|
||||
ContactSelectionListFragment.OnContactSelectedListener,
|
||||
ContactSelectionListFragment.ScrollCallback
|
||||
{
|
||||
private static final String TAG = ContactSelectionActivity.class.getSimpleName();
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
|
||||
public static final String EXTRA_LAYOUT_RES_ID = "layout_res_id";
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
|
||||
protected ContactSelectionListFragment contactsFragment;
|
||||
|
||||
@@ -60,18 +60,17 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB
|
||||
@Override
|
||||
protected void onPreCreate() {
|
||||
dynamicTheme.onCreate(this);
|
||||
dynamicLanguage.onCreate(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle icicle, boolean ready) {
|
||||
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
|
||||
int displayMode = TextSecurePreferences.isSmsEnabled(this) ? DisplayMode.FLAG_ALL
|
||||
: DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS;
|
||||
: DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF;
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
|
||||
}
|
||||
|
||||
setContentView(R.layout.contact_selection_activity);
|
||||
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
|
||||
|
||||
initializeToolbar();
|
||||
initializeResources();
|
||||
@@ -82,7 +81,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
dynamicTheme.onResume(this);
|
||||
dynamicLanguage.onResume(this);
|
||||
}
|
||||
|
||||
protected ContactFilterToolbar getToolbar() {
|
||||
@@ -90,10 +88,9 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
this.toolbar = ViewUtil.findById(this, R.id.toolbar);
|
||||
this.toolbar = findViewById(R.id.toolbar);
|
||||
setSupportActionBar(toolbar);
|
||||
|
||||
assert getSupportActionBar() != null;
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||
getSupportActionBar().setDisplayShowTitleEnabled(false);
|
||||
getSupportActionBar().setIcon(null);
|
||||
@@ -121,6 +118,17 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB
|
||||
@Override
|
||||
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {}
|
||||
|
||||
@Override
|
||||
public void onBeginScroll() {
|
||||
hideKeyboard();
|
||||
}
|
||||
|
||||
private void hideKeyboard() {
|
||||
ServiceUtil.getInputMethodManager(this)
|
||||
.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
|
||||
toolbar.clearFocus();
|
||||
}
|
||||
|
||||
private static class RefreshDirectoryTask extends AsyncTask<Context, Void, Void> {
|
||||
|
||||
private final WeakReference<ContactSelectionActivity> activity;
|
||||
|
||||
@@ -18,47 +18,63 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
|
||||
import android.Manifest;
|
||||
import android.animation.LayoutTransition;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.view.animation.CycleInterpolator;
|
||||
import android.widget.Button;
|
||||
import android.widget.HorizontalScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
import androidx.transition.AutoTransition;
|
||||
import androidx.transition.TransitionManager;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.chip.ChipGroup;
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChip;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
@@ -66,8 +82,9 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -82,13 +99,20 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
|
||||
|
||||
public static final String DISPLAY_MODE = "display_mode";
|
||||
public static final String MULTI_SELECT = "multi_select";
|
||||
public static final String REFRESHABLE = "refreshable";
|
||||
public static final String RECENTS = "recents";
|
||||
private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 1;
|
||||
private static final int CHIP_GROUP_REVEAL_DURATION_MS = 150;
|
||||
|
||||
public static final int NO_LIMIT = Integer.MAX_VALUE;
|
||||
|
||||
public static final String DISPLAY_MODE = "display_mode";
|
||||
public static final String MULTI_SELECT = "multi_select";
|
||||
public static final String REFRESHABLE = "refreshable";
|
||||
public static final String RECENTS = "recents";
|
||||
public static final String TOTAL_CAPACITY = "total_capacity";
|
||||
public static final String CURRENT_SELECTION = "current_selection";
|
||||
|
||||
private ConstraintLayout constraintLayout;
|
||||
private TextView emptyText;
|
||||
private Set<SelectedContact> selectedContacts;
|
||||
private OnContactSelectedListener onContactSelectedListener;
|
||||
private SwipeRefreshLayout swipeRefresh;
|
||||
private View showContactsLayout;
|
||||
@@ -99,16 +123,28 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
private RecyclerView recyclerView;
|
||||
private RecyclerViewFastScroller fastScroller;
|
||||
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
||||
private ChipGroup chipGroup;
|
||||
private HorizontalScrollView chipGroupScrollContainer;
|
||||
private TextView groupLimit;
|
||||
|
||||
@Nullable private FixedViewsAdapter headerAdapter;
|
||||
@Nullable private FixedViewsAdapter footerAdapter;
|
||||
@Nullable private InviteCallback inviteCallback;
|
||||
@Nullable private ListCallback listCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
private GlideRequests glideRequests;
|
||||
private int selectionLimit;
|
||||
private Set<RecipientId> currentSelection;
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
if (context instanceof InviteCallback) {
|
||||
inviteCallback = (InviteCallback) context;
|
||||
if (context instanceof ListCallback) {
|
||||
listCallback = (ListCallback) context;
|
||||
}
|
||||
|
||||
if (context instanceof ScrollCallback) {
|
||||
scrollCallback = (ScrollCallback) context;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,14 +166,16 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
|
||||
handleContactPermissionGranted();
|
||||
} else {
|
||||
this.getLoaderManager().initLoader(0, null, this);
|
||||
LoaderManager.getInstance(this).initLoader(0, null, this);
|
||||
}
|
||||
})
|
||||
.onAnyDenied(() -> {
|
||||
getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
FragmentActivity activity = requireActivity();
|
||||
|
||||
if (getActivity().getIntent().getBooleanExtra(RECENTS, false)) {
|
||||
getLoaderManager().initLoader(0, null, ContactSelectionListFragment.this);
|
||||
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
|
||||
if (activity.getIntent().getBooleanExtra(RECENTS, false)) {
|
||||
LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this);
|
||||
} else {
|
||||
initializeNoContactsPermission();
|
||||
}
|
||||
@@ -149,64 +187,140 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
|
||||
|
||||
emptyText = ViewUtil.findById(view, android.R.id.empty);
|
||||
recyclerView = ViewUtil.findById(view, R.id.recycler_view);
|
||||
swipeRefresh = ViewUtil.findById(view, R.id.swipe_refresh);
|
||||
fastScroller = ViewUtil.findById(view, R.id.fast_scroller);
|
||||
showContactsLayout = view.findViewById(R.id.show_contacts_container);
|
||||
showContactsButton = view.findViewById(R.id.show_contacts_button);
|
||||
showContactsDescription = view.findViewById(R.id.show_contacts_description);
|
||||
showContactsProgress = view.findViewById(R.id.progress);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
showContactsLayout = view.findViewById(R.id.show_contacts_container);
|
||||
showContactsButton = view.findViewById(R.id.show_contacts_button);
|
||||
showContactsDescription = view.findViewById(R.id.show_contacts_description);
|
||||
showContactsProgress = view.findViewById(R.id.progress);
|
||||
chipGroup = view.findViewById(R.id.chipGroup);
|
||||
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
|
||||
groupLimit = view.findViewById(R.id.group_limit);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
|
||||
swipeRefresh.setEnabled(getActivity().getIntent().getBooleanExtra(REFRESHABLE, true));
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
|
||||
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
swipeRefresh.setEnabled(requireActivity().getIntent().getBooleanExtra(REFRESHABLE, true));
|
||||
|
||||
selectionLimit = requireActivity().getIntent().getIntExtra(TOTAL_CAPACITY, NO_LIMIT);
|
||||
currentSelection = getCurrentSelection();
|
||||
|
||||
updateGroupLimit(getChipCount());
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
private void updateGroupLimit(int chipCount) {
|
||||
if (selectionLimit != NO_LIMIT) {
|
||||
groupLimit.setText(String.format(Locale.getDefault(), "%d/%d", currentSelection.size() + chipCount, selectionLimit));
|
||||
groupLimit.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
groupLimit.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
}
|
||||
|
||||
public @NonNull List<SelectedContact> getSelectedContacts() {
|
||||
List<SelectedContact> selected = new LinkedList<>();
|
||||
if (selectedContacts != null) {
|
||||
selected.addAll(selectedContacts);
|
||||
if (cursorRecyclerViewAdapter == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return selected;
|
||||
return cursorRecyclerViewAdapter.getSelectedContacts();
|
||||
}
|
||||
|
||||
private boolean isMulti() {
|
||||
return getActivity().getIntent().getBooleanExtra(MULTI_SELECT, false);
|
||||
public int getSelectedContactsCount() {
|
||||
if (cursorRecyclerViewAdapter == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return cursorRecyclerViewAdapter.getSelectedContactsCount();
|
||||
}
|
||||
|
||||
private Set<RecipientId> getCurrentSelection() {
|
||||
List<RecipientId> currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION);
|
||||
|
||||
return currentSelection == null ? Collections.emptySet()
|
||||
: Collections.unmodifiableSet(Stream.of(currentSelection).collect(Collectors.toSet()));
|
||||
}
|
||||
|
||||
public boolean isMulti() {
|
||||
return requireActivity().getIntent().getBooleanExtra(MULTI_SELECT, false);
|
||||
}
|
||||
|
||||
private void initializeCursor() {
|
||||
glideRequests = GlideApp.with(this);
|
||||
|
||||
cursorRecyclerViewAdapter = new ContactSelectionListAdapter(requireContext(),
|
||||
GlideApp.with(this),
|
||||
glideRequests,
|
||||
null,
|
||||
new ListClickListener(),
|
||||
isMulti());
|
||||
selectedContacts = cursorRecyclerViewAdapter.getSelectedContacts();
|
||||
isMulti(),
|
||||
currentSelection);
|
||||
|
||||
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
|
||||
|
||||
if (listCallback != null && FeatureFlags.newGroupUI()) {
|
||||
if (FeatureFlags.groupsV2create() && FeatureFlags.groupsV2internalTest()) {
|
||||
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback), createNewGroupsV1GroupItem(listCallback));
|
||||
} else {
|
||||
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
|
||||
}
|
||||
headerAdapter.hide();
|
||||
concatenateAdapter.addAdapter(headerAdapter);
|
||||
}
|
||||
|
||||
concatenateAdapter.addAdapter(cursorRecyclerViewAdapter);
|
||||
if (inviteCallback != null) {
|
||||
footerAdapter = new FixedViewsAdapter(createInviteActionView(inviteCallback));
|
||||
|
||||
if (listCallback != null) {
|
||||
footerAdapter = new FixedViewsAdapter(createInviteActionView(listCallback));
|
||||
footerAdapter.hide();
|
||||
concatenateAdapter.addAdapter(footerAdapter);
|
||||
}
|
||||
|
||||
recyclerView.setAdapter(concatenateAdapter);
|
||||
recyclerView.addItemDecoration(new StickyHeaderDecoration(concatenateAdapter, true, true));
|
||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
|
||||
if (scrollCallback != null) {
|
||||
scrollCallback.onBeginScroll();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private View createInviteActionView(@NonNull InviteCallback inviteCallback) {
|
||||
private View createInviteActionView(@NonNull ListCallback listCallback) {
|
||||
View view = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.contact_selection_invite_action_item, (ViewGroup) requireView(), false);
|
||||
view.setOnClickListener(v -> inviteCallback.onInvite());
|
||||
view.setOnClickListener(v -> listCallback.onInvite());
|
||||
return view;
|
||||
}
|
||||
|
||||
private View createNewGroupItem(@NonNull ListCallback listCallback) {
|
||||
View view = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.contact_selection_new_group_item, (ViewGroup) requireView(), false);
|
||||
view.setOnClickListener(v -> listCallback.onNewGroup(false));
|
||||
return view;
|
||||
}
|
||||
|
||||
private View createNewGroupsV1GroupItem(@NonNull ListCallback listCallback) {
|
||||
View view = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.contact_selection_new_group_v1_item, (ViewGroup) requireView(), false);
|
||||
view.setOnClickListener(v -> listCallback.onNewGroup(true));
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -242,23 +356,28 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
swipeRefresh.setRefreshing(false);
|
||||
}
|
||||
|
||||
public boolean hasQueryFilter() {
|
||||
return !TextUtils.isEmpty(cursorFilter);
|
||||
}
|
||||
|
||||
public void setRefreshing(boolean refreshing) {
|
||||
swipeRefresh.setRefreshing(refreshing);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
selectedContacts.clear();
|
||||
cursorRecyclerViewAdapter.clearSelectedContacts();
|
||||
|
||||
if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) {
|
||||
getLoaderManager().restartLoader(0, null, this);
|
||||
LoaderManager.getInstance(this).restartLoader(0, null, this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
return new ContactsCursorLoader(getActivity(),
|
||||
getActivity().getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL),
|
||||
cursorFilter, getActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||
FragmentActivity activity = requireActivity();
|
||||
return new ContactsCursorLoader(activity,
|
||||
activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL),
|
||||
cursorFilter, activity.getIntent().getBooleanExtra(RECENTS, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -272,6 +391,14 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
footerAdapter.show();
|
||||
}
|
||||
|
||||
if (headerAdapter != null) {
|
||||
if (TextUtils.isEmpty(cursorFilter)) {
|
||||
headerAdapter.show();
|
||||
} else {
|
||||
headerAdapter.hide();
|
||||
}
|
||||
}
|
||||
|
||||
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
|
||||
boolean useFastScroller = data != null && data.getCount() > 20;
|
||||
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
|
||||
@@ -336,7 +463,13 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orNull(), contact.getNumber())
|
||||
: SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber());
|
||||
|
||||
if (!isMulti() || !selectedContacts.contains(selectedContact)) {
|
||||
if (!isMulti() || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) {
|
||||
if (selectionLimitReached()) {
|
||||
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_the_group_is_full, Toast.LENGTH_SHORT).show();
|
||||
groupLimit.animate().scaleX(1.3f).scaleY(1.3f).setInterpolator(new CycleInterpolator(0.5f)).start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (contact.isUsernameType()) {
|
||||
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
|
||||
|
||||
@@ -346,8 +479,9 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
loadingDialog.dismiss();
|
||||
if (uuid.isPresent()) {
|
||||
Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber());
|
||||
selectedContacts.add(SelectedContact.forUsername(recipient.getId(), contact.getNumber()));
|
||||
contact.setChecked(true);
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
||||
markContactSelected(selected);
|
||||
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactSelected(Optional.of(recipient.getId()), null);
|
||||
@@ -361,22 +495,126 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
}
|
||||
});
|
||||
} else {
|
||||
selectedContacts.add(selectedContact);
|
||||
contact.setChecked(true);
|
||||
markContactSelected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactSelected(contact.getRecipientId(), contact.getNumber());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedContacts.remove(selectedContact);
|
||||
contact.setChecked(false);
|
||||
markContactUnselected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
private boolean selectionLimitReached() {
|
||||
return getChipCount() >= selectionLimit;
|
||||
}
|
||||
|
||||
private void markContactSelected(@NonNull SelectedContact selectedContact) {
|
||||
cursorRecyclerViewAdapter.addSelectedContact(selectedContact);
|
||||
if (isMulti() && FeatureFlags.newGroupUI()) {
|
||||
addChipForSelectedContact(selectedContact);
|
||||
}
|
||||
}
|
||||
|
||||
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
||||
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
removeChipForContact(selectedContact);
|
||||
}
|
||||
|
||||
private void removeChipForContact(@NonNull SelectedContact contact) {
|
||||
for (int i = chipGroup.getChildCount() - 1; i >= 0; i--) {
|
||||
View v = chipGroup.getChildAt(i);
|
||||
if (v instanceof ContactChip && contact.matches(((ContactChip) v).getContact())) {
|
||||
chipGroup.removeView(v);
|
||||
}
|
||||
}
|
||||
|
||||
updateGroupLimit(getChipCount());
|
||||
|
||||
if (getChipCount() == 0) {
|
||||
setChipGroupVisibility(ConstraintSet.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> addChipForRecipient(resolved, selectedContact));
|
||||
}
|
||||
|
||||
private void addChipForRecipient(@NonNull Recipient recipient, @NonNull SelectedContact selectedContact) {
|
||||
final ContactChip chip = new ContactChip(requireContext());
|
||||
|
||||
if (getChipCount() == 0) {
|
||||
setChipGroupVisibility(ConstraintSet.VISIBLE);
|
||||
}
|
||||
|
||||
chip.setText(recipient.getShortDisplayName(requireContext()));
|
||||
chip.setContact(selectedContact);
|
||||
chip.setCloseIconVisible(true);
|
||||
chip.setOnCloseIconClickListener(view -> {
|
||||
markContactUnselected(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.of(recipient.getId()), recipient.getE164().orNull());
|
||||
}
|
||||
});
|
||||
|
||||
chipGroup.getLayoutTransition().addTransitionListener(new LayoutTransition.TransitionListener() {
|
||||
@Override
|
||||
public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
|
||||
if (view == chip && transitionType == LayoutTransition.APPEARING) {
|
||||
chipGroup.getLayoutTransition().removeTransitionListener(this);
|
||||
registerChipRecipientObserver(chip, recipient.live());
|
||||
chipGroup.post(ContactSelectionListFragment.this::smoothScrollChipsToEnd);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chip.setAvatar(glideRequests, recipient, () -> addChip(chip));
|
||||
}
|
||||
|
||||
private void addChip(@NonNull ContactChip chip) {
|
||||
chipGroup.addView(chip);
|
||||
updateGroupLimit(getChipCount());
|
||||
}
|
||||
|
||||
private int getChipCount() {
|
||||
int count = chipGroup.getChildCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
|
||||
if (count < 0) throw new AssertionError();
|
||||
return count;
|
||||
}
|
||||
|
||||
private void registerChipRecipientObserver(@NonNull ContactChip chip, @Nullable LiveRecipient recipient) {
|
||||
if (recipient != null) {
|
||||
recipient.observe(getViewLifecycleOwner(), resolved -> {
|
||||
if (chip.isAttachedToWindow()) {
|
||||
chip.setAvatar(glideRequests, resolved, null);
|
||||
chip.setText(resolved.getShortDisplayName(chip.getContext()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void setChipGroupVisibility(int visibility) {
|
||||
TransitionManager.beginDelayedTransition(constraintLayout, new AutoTransition().setDuration(CHIP_GROUP_REVEAL_DURATION_MS));
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(constraintLayout);
|
||||
constraintSet.setVisibility(R.id.chipGroupScrollContainer, visibility);
|
||||
constraintSet.applyTo(constraintLayout);
|
||||
}
|
||||
|
||||
public void setOnContactSelectedListener(OnContactSelectedListener onContactSelectedListener) {
|
||||
@@ -387,12 +625,22 @@ public final class ContactSelectionListFragment extends Fragment
|
||||
this.swipeRefresh.setOnRefreshListener(onRefreshListener);
|
||||
}
|
||||
|
||||
private void smoothScrollChipsToEnd() {
|
||||
int x = chipGroupScrollContainer.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR ? chipGroup.getWidth() : 0;
|
||||
chipGroupScrollContainer.smoothScrollTo(x, 0);
|
||||
}
|
||||
|
||||
public interface OnContactSelectedListener {
|
||||
void onContactSelected(Optional<RecipientId> recipientId, String number);
|
||||
void onContactDeselected(Optional<RecipientId> recipientId, String number);
|
||||
}
|
||||
|
||||
public interface InviteCallback {
|
||||
public interface ListCallback {
|
||||
void onInvite();
|
||||
void onNewGroup(boolean forceV1);
|
||||
}
|
||||
|
||||
public interface ScrollCallback {
|
||||
void onBeginScroll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.ListFragment;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ListView;
|
||||
import android.widget.SimpleAdapter;
|
||||
|
||||
|
||||
import org.thoughtcrime.securesms.database.loaders.CountryListLoader;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
|
||||
public class CountrySelectionFragment extends ListFragment implements LoaderManager.LoaderCallbacks<ArrayList<Map<String, String>>> {
|
||||
|
||||
private EditText countryFilter;
|
||||
private CountrySelectedListener listener;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) {
|
||||
return inflater.inflate(R.layout.country_selection_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(Bundle bundle) {
|
||||
super.onActivityCreated(bundle);
|
||||
this.countryFilter = (EditText)getView().findViewById(R.id.country_search);
|
||||
this.countryFilter.addTextChangedListener(new FilterWatcher());
|
||||
getLoaderManager().initLoader(0, null, this).forceLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Activity activity) {
|
||||
super.onAttach(activity);
|
||||
this.listener = (CountrySelectedListener)activity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onListItemClick(ListView listView, View view, int position, long id) {
|
||||
Map<String, String> item = (Map<String, String>)this.getListAdapter().getItem(position);
|
||||
if (this.listener != null) {
|
||||
this.listener.countrySelected(item.get("country_name"),
|
||||
Integer.parseInt(item.get("country_code").substring(1)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<ArrayList<Map<String, String>>> onCreateLoader(int arg0, Bundle arg1) {
|
||||
return new CountryListLoader(getActivity());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<ArrayList<Map<String, String>>> loader,
|
||||
ArrayList<Map<String, String>> results)
|
||||
{
|
||||
String[] from = {"country_name", "country_code"};
|
||||
int[] to = {R.id.country_name, R.id.country_code};
|
||||
this.setListAdapter(new SimpleAdapter(getActivity(), results, R.layout.country_list_item, from, to));
|
||||
|
||||
if (this.countryFilter != null && this.countryFilter.getText().length() != 0) {
|
||||
((SimpleAdapter)getListAdapter()).getFilter().filter(this.countryFilter.getText().toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<ArrayList<Map<String, String>>> arg0) {
|
||||
this.setListAdapter(null);
|
||||
}
|
||||
|
||||
public interface CountrySelectedListener {
|
||||
public void countrySelected(String countryName, int countryCode);
|
||||
}
|
||||
|
||||
private class FilterWatcher implements TextWatcher {
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
if (getListAdapter() != null) {
|
||||
((SimpleAdapter)getListAdapter()).getFilter().filter(s.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,25 +164,29 @@ public class DeviceListFragment extends ListFragment
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleDisconnectDevice(final long deviceId) {
|
||||
new ProgressDialogAsyncTask<Void, Void, Void>(getActivity(),
|
||||
R.string.DeviceListActivity_unlinking_device_no_ellipsis,
|
||||
R.string.DeviceListActivity_unlinking_device)
|
||||
new ProgressDialogAsyncTask<Void, Void, Boolean>(getActivity(),
|
||||
R.string.DeviceListActivity_unlinking_device_no_ellipsis,
|
||||
R.string.DeviceListActivity_unlinking_device)
|
||||
{
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
protected Boolean doInBackground(Void... params) {
|
||||
try {
|
||||
accountManager.removeDevice(deviceId);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Void result) {
|
||||
protected void onPostExecute(Boolean result) {
|
||||
super.onPostExecute(result);
|
||||
getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
|
||||
if (result) {
|
||||
getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
|
||||
} else {
|
||||
Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,6 @@ public class DeviceProvisioningActivity extends PassphraseRequiredActionBarActiv
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle bundle, boolean ready) {
|
||||
assert getSupportActionBar() != null;
|
||||
getSupportActionBar().hide();
|
||||
|
||||
AlertDialog dialog = new AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.DeviceProvisioningActivity_link_a_signal_device))
|
||||
.setMessage(getString(R.string.DeviceProvisioningActivity_it_looks_like_youre_trying_to_link_a_signal_device_using_a_3rd_party_scanner))
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
@@ -65,18 +66,17 @@ import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.SelectedRecipientsAdapter;
|
||||
import org.thoughtcrime.securesms.util.SelectedRecipientsAdapter.OnRecipientDeletedListener;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.util.InvalidNumberException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
@@ -86,7 +86,7 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Activity to create and update groups
|
||||
* Activity to create and update {@link GroupId.V1} groups
|
||||
*
|
||||
* @author Jake McGinty
|
||||
*/
|
||||
@@ -97,27 +97,31 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
|
||||
private final static String TAG = GroupCreateActivity.class.getSimpleName();
|
||||
|
||||
public static final String GROUP_ID_EXTRA = "group_id";
|
||||
public static final String GROUP_THREAD_EXTRA = "group_thread";
|
||||
private static final String GROUP_ID_EXTRA = "group_id";
|
||||
private static final String GROUP_THREAD_EXTRA = "group_thread";
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicTheme();
|
||||
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
|
||||
private final DynamicTheme dynamicTheme = new DynamicTheme();
|
||||
|
||||
private static final short REQUEST_CODE_SELECT_AVATAR = 26165;
|
||||
private static final int PICK_CONTACT = 1;
|
||||
|
||||
private EditText groupName;
|
||||
private ListView lv;
|
||||
private ImageView avatar;
|
||||
private TextView creatingText;
|
||||
private Bitmap avatarBmp;
|
||||
private EditText groupName;
|
||||
private ListView listView;
|
||||
private ImageView avatar;
|
||||
private TextView creatingText;
|
||||
private Bitmap avatarBmp;
|
||||
|
||||
@NonNull private Optional<GroupData> groupToUpdate = Optional.absent();
|
||||
|
||||
public static Intent newEditGroupIntent(@NonNull Context context, @NonNull GroupId.V1 groupId) {
|
||||
Intent intent = new Intent(context, GroupCreateActivity.class);
|
||||
intent.putExtra(GroupCreateActivity.GROUP_ID_EXTRA, groupId.toString());
|
||||
return intent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPreCreate() {
|
||||
dynamicTheme.onCreate(this);
|
||||
dynamicLanguage.onCreate(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -133,7 +137,6 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
dynamicTheme.onResume(this);
|
||||
dynamicLanguage.onResume(this);
|
||||
updateViewState();
|
||||
}
|
||||
|
||||
@@ -190,20 +193,25 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private void initializeResources() {
|
||||
RecipientsEditor recipientsEditor = ViewUtil.findById(this, R.id.recipients_text);
|
||||
PushRecipientsPanel recipientsPanel = ViewUtil.findById(this, R.id.recipients);
|
||||
lv = ViewUtil.findById(this, R.id.selected_contacts_list);
|
||||
avatar = ViewUtil.findById(this, R.id.avatar);
|
||||
groupName = ViewUtil.findById(this, R.id.group_name);
|
||||
creatingText = ViewUtil.findById(this, R.id.creating_group_text);
|
||||
RecipientsEditor recipientsEditor = findViewById(R.id.recipients_text);
|
||||
PushRecipientsPanel recipientsPanel = findViewById(R.id.recipients);
|
||||
|
||||
listView = findViewById(R.id.selected_contacts_list);
|
||||
avatar = findViewById(R.id.avatar);
|
||||
groupName = findViewById(R.id.group_name);
|
||||
creatingText = findViewById(R.id.creating_group_text);
|
||||
|
||||
SelectedRecipientsAdapter adapter = new SelectedRecipientsAdapter(this);
|
||||
adapter.setOnRecipientDeletedListener(this);
|
||||
lv.setAdapter(adapter);
|
||||
listView.setAdapter(adapter);
|
||||
|
||||
recipientsEditor.setHint(R.string.recipients_panel__add_members);
|
||||
recipientsPanel.setPanelChangeListener(this);
|
||||
|
||||
findViewById(R.id.contacts_button).setOnClickListener(new AddRecipientButtonListener());
|
||||
|
||||
avatar.setImageDrawable(getDefaultGroupAvatar());
|
||||
avatar.setOnClickListener(view -> AvatarSelectionBottomSheetDialogFragment.create(avatarBmp != null, false, REQUEST_CODE_SELECT_AVATAR).show(getSupportFragmentManager(), null));
|
||||
avatar.setOnClickListener(view -> AvatarSelectionBottomSheetDialogFragment.create(avatarBmp != null, false, REQUEST_CODE_SELECT_AVATAR, true).show(getSupportFragmentManager(), null));
|
||||
}
|
||||
|
||||
private Drawable getDefaultGroupAvatar() {
|
||||
@@ -214,7 +222,13 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
final GroupId groupId = GroupId.parseNullableOrThrow(getIntent().getStringExtra(GROUP_ID_EXTRA));
|
||||
|
||||
if (groupId != null) {
|
||||
new FillExistingGroupInfoAsyncTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, groupId);
|
||||
GroupId.V1 groupIdV1 = groupId.requireV1();
|
||||
|
||||
new FillExistingGroupInfoAsyncTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, groupIdV1);
|
||||
|
||||
if (FeatureFlags.newGroupUI()) {
|
||||
avatar.setOnClickListener(v -> startActivity(EditProfileActivity.getIntentForGroupProfile(this, groupIdV1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,8 +283,8 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private void handleGroupUpdate() {
|
||||
new UpdateSignalGroupTask(this, groupToUpdate.get().id, avatarBmp,
|
||||
getGroupName(), getAdapter().getRecipients()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
new UpdateSignalGroupV1Task(this, groupToUpdate.get().id, avatarBmp,
|
||||
getGroupName(), getAdapter().getRecipients()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
|
||||
private void handleOpenConversation(long threadId, Recipient recipient) {
|
||||
@@ -283,7 +297,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private SelectedRecipientsAdapter getAdapter() {
|
||||
return (SelectedRecipientsAdapter)lv.getAdapter();
|
||||
return (SelectedRecipientsAdapter) listView.getAdapter();
|
||||
}
|
||||
|
||||
private @Nullable String getGroupName() {
|
||||
@@ -428,7 +442,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
|
||||
@Override
|
||||
protected Optional<GroupActionResult> doInBackground(Void... aVoid) {
|
||||
return Optional.of(GroupManager.createGroup(activity, members, avatar, name, false));
|
||||
return Optional.of(GroupManager.createGroupV1(activity, members, BitmapUtil.toByteArray(avatar), name, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -445,11 +459,11 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
}
|
||||
|
||||
private static class UpdateSignalGroupTask extends SignalGroupTask {
|
||||
private final GroupId groupId;
|
||||
private static class UpdateSignalGroupV1Task extends SignalGroupTask {
|
||||
private final GroupId.V1 groupId;
|
||||
|
||||
public UpdateSignalGroupTask(GroupCreateActivity activity, GroupId groupId,
|
||||
Bitmap avatar, String name, Set<Recipient> members)
|
||||
UpdateSignalGroupV1Task(GroupCreateActivity activity, GroupId.V1 groupId,
|
||||
Bitmap avatar, String name, Set<Recipient> members)
|
||||
{
|
||||
super(activity, avatar, name, members);
|
||||
this.groupId = groupId;
|
||||
@@ -457,11 +471,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
|
||||
@Override
|
||||
protected Optional<GroupActionResult> doInBackground(Void... aVoid) {
|
||||
try {
|
||||
return Optional.of(GroupManager.updateGroup(activity, groupId, members, avatar, name));
|
||||
} catch (InvalidNumberException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
return Optional.fromNullable(GroupManager.updateGroup(activity, groupId, members, BitmapUtil.toByteArray(avatar), name));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -537,7 +547,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
}
|
||||
|
||||
private static class FillExistingGroupInfoAsyncTask extends ProgressDialogAsyncTask<GroupId, Void, Optional<GroupData>> {
|
||||
private static class FillExistingGroupInfoAsyncTask extends ProgressDialogAsyncTask<GroupId.V1, Void, Optional<GroupData>> {
|
||||
private GroupCreateActivity activity;
|
||||
|
||||
public FillExistingGroupInfoAsyncTask(GroupCreateActivity activity) {
|
||||
@@ -548,7 +558,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Optional<GroupData> doInBackground(GroupId... groupIds) {
|
||||
protected Optional<GroupData> doInBackground(GroupId.V1... groupIds) {
|
||||
final GroupDatabase db = DatabaseFactory.getGroupDatabase(activity);
|
||||
final List<Recipient> recipients = db.getGroupMembers(groupIds[0], GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF);
|
||||
final Optional<GroupRecord> group = db.getGroup(groupIds[0]);
|
||||
@@ -585,7 +595,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
SelectedRecipientsAdapter adapter = new SelectedRecipientsAdapter(activity, group.get().recipients);
|
||||
adapter.setOnRecipientDeletedListener(activity);
|
||||
activity.lv.setAdapter(adapter);
|
||||
activity.listView.setAdapter(adapter);
|
||||
activity.updateViewState();
|
||||
}
|
||||
}
|
||||
@@ -602,13 +612,13 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private static class GroupData {
|
||||
GroupId id;
|
||||
GroupId.V1 id;
|
||||
Set<Recipient> recipients;
|
||||
Bitmap avatarBmp;
|
||||
byte[] avatarBytes;
|
||||
String name;
|
||||
|
||||
GroupData(GroupId id, Set<Recipient> recipients, Bitmap avatarBmp, byte[] avatarBytes, String name) {
|
||||
GroupData(GroupId.V1 id, Set<Recipient> recipients, Bitmap avatarBmp, byte[] avatarBytes, String name) {
|
||||
this.id = id;
|
||||
this.recipients = recipients;
|
||||
this.avatarBmp = avatarBmp;
|
||||
|
||||
@@ -1,82 +1,57 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientExporter;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class GroupMembersDialog {
|
||||
|
||||
private final Context context;
|
||||
private final Recipient groupRecipient;
|
||||
private final Lifecycle lifecycle;
|
||||
private final FragmentActivity fragmentActivity;
|
||||
private final Recipient groupRecipient;
|
||||
|
||||
public GroupMembersDialog(@NonNull Context context,
|
||||
@NonNull Recipient groupRecipient,
|
||||
@NonNull Lifecycle lifecycle)
|
||||
public GroupMembersDialog(@NonNull FragmentActivity activity,
|
||||
@NonNull Recipient groupRecipient)
|
||||
{
|
||||
this.context = context;
|
||||
this.groupRecipient = groupRecipient;
|
||||
this.lifecycle = lifecycle;
|
||||
this.fragmentActivity = activity;
|
||||
this.groupRecipient = groupRecipient;
|
||||
}
|
||||
|
||||
public void display() {
|
||||
SimpleTask.run(
|
||||
lifecycle,
|
||||
() -> DatabaseFactory.getGroupDatabase(context).getGroupMembers(groupRecipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF),
|
||||
members -> {
|
||||
AlertDialog dialog = new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.ConversationActivity_group_members)
|
||||
.setIconAttribute(R.attr.group_members_dialog_icon)
|
||||
.setCancelable(true)
|
||||
.setView(R.layout.dialog_group_members)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
AlertDialog dialog = new AlertDialog.Builder(fragmentActivity)
|
||||
.setTitle(R.string.ConversationActivity_group_members)
|
||||
.setIconAttribute(R.attr.group_members_dialog_icon)
|
||||
.setCancelable(true)
|
||||
.setView(R.layout.dialog_group_members)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
|
||||
GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
|
||||
GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
|
||||
|
||||
ArrayList<GroupMemberEntry.FullMember> pendingMembers = new ArrayList<>(members.size());
|
||||
for (Recipient member : members) {
|
||||
GroupMemberEntry.FullMember entry = new GroupMemberEntry.FullMember(member);
|
||||
LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId());
|
||||
LiveData<List<GroupMemberEntry.FullMember>> fullMembers = liveGroup.getFullMembers();
|
||||
|
||||
entry.setOnClick(() -> {
|
||||
dialog.dismiss();
|
||||
contactClick(member);
|
||||
});
|
||||
//noinspection ConstantConditions
|
||||
fullMembers.observe(fragmentActivity, memberListView::setMembers);
|
||||
|
||||
if (member.isLocalNumber()) {
|
||||
pendingMembers.add(0, entry);
|
||||
} else {
|
||||
pendingMembers.add(entry);
|
||||
}
|
||||
}
|
||||
dialog.setOnDismissListener(d -> fullMembers.removeObservers(fragmentActivity));
|
||||
|
||||
//noinspection ConstantConditions
|
||||
memberListView.setMembers(pendingMembers);
|
||||
}
|
||||
);
|
||||
memberListView.setRecipientClickListener(recipient -> {
|
||||
dialog.dismiss();
|
||||
contactClick(recipient);
|
||||
});
|
||||
}
|
||||
|
||||
private void contactClick(@NonNull Recipient recipient) {
|
||||
if (recipient.getContactUri() != null) {
|
||||
Intent intent = new Intent(context, RecipientPreferenceActivity.class);
|
||||
intent.putExtra(RecipientPreferenceActivity.RECIPIENT_ID, recipient.getId());
|
||||
|
||||
context.startActivity(intent);
|
||||
} else {
|
||||
context.startActivity(RecipientExporter.export(recipient).asAddContactIntent());
|
||||
}
|
||||
RecipientBottomSheetDialogFragment.create(recipient.getId(), groupRecipient.requireGroupId())
|
||||
.show(fragmentActivity.getSupportFragmentManager(), "BOTTOM");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar.OnFilterChangedListener;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
@@ -42,6 +40,7 @@ import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public class InviteActivity extends PassphraseRequiredActionBarActivity implements ContactSelectionListFragment.OnContactSelectedListener {
|
||||
@@ -135,14 +134,15 @@ public class InviteActivity extends PassphraseRequiredActionBarActivity implemen
|
||||
new SendSmsInvitesAsyncTask(this, inviteText.getText().toString())
|
||||
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
|
||||
contactsFragment.getSelectedContacts()
|
||||
.toArray(new SelectedContact[contactsFragment.getSelectedContacts().size()]));
|
||||
.toArray(new SelectedContact[0]));
|
||||
}
|
||||
|
||||
private void updateSmsButtonText() {
|
||||
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
|
||||
smsSendButton.setText(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_to_friends,
|
||||
contactsFragment.getSelectedContacts().size(),
|
||||
contactsFragment.getSelectedContacts().size()));
|
||||
smsSendButton.setEnabled(!contactsFragment.getSelectedContacts().isEmpty());
|
||||
selectedContacts.size(),
|
||||
selectedContacts.size()));
|
||||
smsSendButton.setEnabled(!selectedContacts.isEmpty());
|
||||
}
|
||||
|
||||
@Override public void onBackPressed() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.fragment.app.FragmentManager;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment;
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
@@ -55,8 +56,8 @@ public class MainNavigator {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, long lastSeen, int startingPosition) {
|
||||
Intent intent = ConversationActivity.buildIntent(activity, recipientId, threadId, distributionType, lastSeen, startingPosition);
|
||||
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition) {
|
||||
Intent intent = ConversationActivity.buildIntent(activity, recipientId, threadId, distributionType, startingPosition);
|
||||
|
||||
activity.startActivity(intent);
|
||||
activity.overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out);
|
||||
@@ -77,8 +78,7 @@ public class MainNavigator {
|
||||
}
|
||||
|
||||
public void goToGroupCreation() {
|
||||
Intent intent = new Intent(activity, GroupCreateActivity.class);
|
||||
activity.startActivity(intent);
|
||||
activity.startActivity(CreateGroupActivity.newIntent(activity));
|
||||
}
|
||||
|
||||
public void goToInvite() {
|
||||
|
||||
@@ -32,7 +32,10 @@ import androidx.loader.content.Loader;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItem;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import android.os.Parcelable;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
@@ -52,7 +55,6 @@ import org.thoughtcrime.securesms.database.loaders.MessageDetailsLoader;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
@@ -62,6 +64,7 @@ import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
@@ -132,13 +135,13 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
|
||||
assert getSupportActionBar() != null;
|
||||
getSupportActionBar().setTitle(R.string.AndroidManifest__message_details);
|
||||
|
||||
MessageNotifier.setVisibleThread(threadId);
|
||||
ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
MessageNotifier.setVisibleThread(-1L);
|
||||
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -269,7 +272,9 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
toFrom.setText(toFromRes);
|
||||
conversationItem.bind(messageRecord, Optional.absent(), Optional.absent(), glideRequests, dynamicLanguage.getCurrentLocale(), new HashSet<>(), recipient, null, false);
|
||||
Parcelable state = recipientsList.onSaveInstanceState();
|
||||
recipientsList.setAdapter(new MessageDetailsRecipientAdapter(this, glideRequests, messageRecord, recipients, isPushGroup));
|
||||
recipientsList.onRestoreInstanceState(state);
|
||||
}
|
||||
|
||||
private void inflateMessageViewIfAbsent(MessageRecord messageRecord) {
|
||||
@@ -277,9 +282,9 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
|
||||
if (messageRecord.isGroupAction()) {
|
||||
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_update, itemParent, false);
|
||||
} else if (messageRecord.isOutgoing()) {
|
||||
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_sent, itemParent, false);
|
||||
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_sent_multimedia, itemParent, false);
|
||||
} else {
|
||||
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_received, itemParent, false);
|
||||
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_received_multimedia, itemParent, false);
|
||||
}
|
||||
itemParent.addView(conversationItem);
|
||||
}
|
||||
@@ -439,8 +444,8 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private void onResendClicked(View v) {
|
||||
MessageSender.resend(MessageDetailsActivity.this, messageRecord);
|
||||
resendButton.setVisibility(View.GONE);
|
||||
SignalExecutors.BOUNDED.execute(() -> MessageSender.resend(MessageDetailsActivity.this, messageRecord));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,23 +81,45 @@ public class MessageRecipientListItem extends RelativeLayout
|
||||
this.deliveryStatusView = findViewById(R.id.delivery_status);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
observeMember();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
unsubscribeFromMember();
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
public void set(final GlideRequests glideRequests,
|
||||
final MessageRecord record,
|
||||
final RecipientDeliveryStatus member,
|
||||
final boolean isPushGroup)
|
||||
{
|
||||
if (this.member != null) this.member.getRecipient().live().removeForeverObserver(this);
|
||||
unsubscribeFromMember();
|
||||
|
||||
this.glideRequests = glideRequests;
|
||||
this.member = member;
|
||||
observeMember();
|
||||
|
||||
member.getRecipient().live().observeForever(this);
|
||||
fromView.setText(member.getRecipient());
|
||||
contactPhotoImage.setAvatar(glideRequests, member.getRecipient(), false);
|
||||
setIssueIndicators(record, isPushGroup);
|
||||
unidentifiedDeliveryIcon.setVisibility(TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()) && member.isUnidentified() ? VISIBLE : GONE);
|
||||
}
|
||||
|
||||
private void observeMember() {
|
||||
if (isAttachedToWindow() && member != null && member.getRecipient() != null) {
|
||||
member.getRecipient().live().observeForever(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void unsubscribeFromMember() {
|
||||
if (member != null && member.getRecipient() != null) member.getRecipient().live().removeForeverObserver(this);
|
||||
}
|
||||
|
||||
private void setIssueIndicators(final MessageRecord record,
|
||||
final boolean isPushGroup)
|
||||
{
|
||||
@@ -162,7 +184,7 @@ public class MessageRecipientListItem extends RelativeLayout
|
||||
}
|
||||
|
||||
public void unbind() {
|
||||
if (this.member != null && this.member.getRecipient() != null) this.member.getRecipient().live().removeForeverObserver(this);
|
||||
unsubscribeFromMember();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -23,6 +25,10 @@ public class MuteDialog extends AlertDialog {
|
||||
}
|
||||
|
||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
|
||||
show(context, listener, null);
|
||||
}
|
||||
|
||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
||||
builder.setTitle(R.string.MuteDialog_mute_notifications);
|
||||
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
|
||||
@@ -43,6 +49,13 @@ public class MuteDialog extends AlertDialog {
|
||||
}
|
||||
});
|
||||
|
||||
if (cancelListener != null) {
|
||||
builder.setOnCancelListener(dialog -> {
|
||||
cancelListener.run();
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
builder.show();
|
||||
|
||||
}
|
||||
|
||||
@@ -19,32 +19,16 @@ package org.thoughtcrime.securesms;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Activity container for starting a new conversation.
|
||||
@@ -53,7 +37,7 @@ import java.util.UUID;
|
||||
*
|
||||
*/
|
||||
public class NewConversationActivity extends ContactSelectionActivity
|
||||
implements ContactSelectionListFragment.InviteCallback
|
||||
implements ContactSelectionListFragment.ListCallback
|
||||
{
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@@ -112,7 +96,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
private void handleCreateGroup() {
|
||||
startActivity(new Intent(this, GroupCreateActivity.class));
|
||||
startActivity(CreateGroupActivity.newIntent(this));
|
||||
}
|
||||
|
||||
private void handleInvite() {
|
||||
@@ -120,10 +104,10 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onPrepareOptionsPanel(View view, Menu menu) {
|
||||
MenuInflater inflater = this.getMenuInflater();
|
||||
public boolean onPrepareOptionsMenu(Menu menu) {
|
||||
menu.clear();
|
||||
inflater.inflate(R.menu.new_conversation_activity, menu);
|
||||
getMenuInflater().inflate(R.menu.new_conversation_activity, menu);
|
||||
|
||||
super.onPrepareOptionsMenu(menu);
|
||||
return true;
|
||||
}
|
||||
@@ -131,5 +115,12 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
@Override
|
||||
public void onInvite() {
|
||||
handleInvite();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewGroup(boolean forceV1) {
|
||||
handleCreateGroup();
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
public class PassphraseChangeActivity extends PassphraseActivity {
|
||||
|
||||
private static final String TAG = Log.tag(PassphraseChangeActivity.class);
|
||||
|
||||
private DynamicTheme dynamicTheme = new DynamicTheme();
|
||||
private DynamicLanguage dynamicLanguage = new DynamicLanguage();
|
||||
|
||||
@@ -145,7 +147,7 @@ public class PassphraseChangeActivity extends PassphraseActivity {
|
||||
return masterSecret;
|
||||
|
||||
} catch (InvalidPassphraseException e) {
|
||||
Log.w(PassphraseChangeActivity.class.getSimpleName(), e);
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.pin.PinRestoreActivity;
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName;
|
||||
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -186,7 +185,7 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
|
||||
}
|
||||
|
||||
private boolean userMustCreateSignalPin() {
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin();
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed();
|
||||
}
|
||||
|
||||
private boolean userMustSetProfileName() {
|
||||
|
||||
@@ -45,16 +45,24 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
|
||||
getIntent().putExtra(ContactSelectionListFragment.MULTI_SELECT, true);
|
||||
super.onCreate(icicle, ready);
|
||||
|
||||
initializeToolbar();
|
||||
}
|
||||
|
||||
protected void initializeToolbar() {
|
||||
getToolbar().setNavigationIcon(R.drawable.ic_check_24);
|
||||
getToolbar().setNavigationOnClickListener(v -> {
|
||||
Intent resultIntent = getIntent();
|
||||
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
|
||||
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId(this)).toList();
|
||||
|
||||
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
|
||||
|
||||
setResult(RESULT_OK, resultIntent);
|
||||
finish();
|
||||
onFinishedSelection();
|
||||
});
|
||||
}
|
||||
|
||||
protected final void onFinishedSelection() {
|
||||
Intent resultIntent = getIntent();
|
||||
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
|
||||
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId(this)).toList();
|
||||
|
||||
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
|
||||
|
||||
setResult(RESULT_OK, resultIntent);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.media.Ringtone;
|
||||
import android.media.RingtoneManager;
|
||||
import android.net.Uri;
|
||||
@@ -21,6 +22,7 @@ import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -35,7 +37,11 @@ import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceCategory;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.load.DataSource;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.engine.GlideException;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
import com.google.android.material.appbar.CollapsingToolbarLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
@@ -69,12 +75,13 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
@@ -100,8 +107,7 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
|
||||
private static final String PREFERENCE_ABOUT = "pref_key_number";
|
||||
private static final String PREFERENCE_CUSTOM_NOTIFICATIONS = "pref_key_recipient_custom_notifications";
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme();
|
||||
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
|
||||
private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme();
|
||||
|
||||
private ImageView avatar;
|
||||
private GlideRequests glideRequests;
|
||||
@@ -120,7 +126,6 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
|
||||
@Override
|
||||
public void onPreCreate() {
|
||||
dynamicTheme.onCreate(this);
|
||||
dynamicLanguage.onCreate(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -135,14 +140,13 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
|
||||
setHeader(recipient.get());
|
||||
recipient.observe(this, this::setHeader);
|
||||
|
||||
getSupportLoaderManager().initLoader(0, null, this);
|
||||
LoaderManager.getInstance(this).initLoader(0, null, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
dynamicTheme.onResume(this);
|
||||
dynamicLanguage.onResume(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -165,10 +169,10 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
this.toolbarLayout = ViewUtil.findById(this, R.id.collapsing_toolbar);
|
||||
this.avatar = ViewUtil.findById(this, R.id.avatar);
|
||||
this.threadPhotoRailView = ViewUtil.findById(this, R.id.recent_photos);
|
||||
this.threadPhotoRailLabel = ViewUtil.findById(this, R.id.rail_label);
|
||||
this.toolbarLayout = findViewById(R.id.collapsing_toolbar);
|
||||
this.avatar = findViewById(R.id.avatar);
|
||||
this.threadPhotoRailView = findViewById(R.id.recent_photos);
|
||||
this.threadPhotoRailLabel = findViewById(R.id.rail_label);
|
||||
|
||||
this.toolbarLayout.setExpandedTitleColor(ThemeUtil.getThemedColor(this, R.attr.conversation_title_color));
|
||||
this.toolbarLayout.setCollapsedTitleTextColor(ThemeUtil.getThemedColor(this, R.attr.conversation_title_color));
|
||||
@@ -189,7 +193,7 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
|
||||
}
|
||||
);
|
||||
|
||||
Toolbar toolbar = ViewUtil.findById(this, R.id.toolbar);
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
setSupportActionBar(toolbar);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setLogo(null);
|
||||
@@ -215,6 +219,20 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
|
||||
.fallback(fallbackPhoto.asCallCard(this))
|
||||
.error(fallbackPhoto.asCallCard(this))
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.addListener(new RequestListener<Drawable>() {
|
||||
@Override
|
||||
public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
|
||||
avatar.setOnClickListener(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
|
||||
avatar.setOnClickListener(v -> startActivity(AvatarPreviewActivity.intentFromRecipientId(RecipientPreferenceActivity.this, recipient.getId()),
|
||||
AvatarPreviewActivity.createTransitionBundle(RecipientPreferenceActivity.this, avatar)));
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.into(this.avatar);
|
||||
|
||||
if (contactPhoto == null) this.avatar.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
|
||||
@@ -713,6 +731,15 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
|
||||
public void onInSecureCallClicked() {
|
||||
CommunicationActions.startInsecureCall(requireActivity(), recipient.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongClick() {
|
||||
if (recipient.get().hasE164()) {
|
||||
Util.copyToClipboard(requireContext(), recipient.get().requireE164());
|
||||
ServiceUtil.getVibrator(requireContext()).vibrate(250);
|
||||
Toast.makeText(requireContext(), R.string.RecipientBottomSheet_copied_to_clipboard, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CustomNotificationsChangedListener implements Preference.OnPreferenceChangeListener {
|
||||
|
||||
@@ -18,43 +18,56 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.app.PictureInPictureParams;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.DialogInterface.OnClickListener;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Configuration;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import android.view.View;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.util.Rational;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcAnswerDeclineButton;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallControls;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallScreen;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
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.crypto.storage.TextSecureIdentityKeyStore;
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
|
||||
import org.thoughtcrime.securesms.service.WebRtcCallService;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.VerifySpan;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.webrtc.SurfaceViewRenderer;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||
|
||||
import static org.whispersystems.libsignal.SessionCipher.SESSION_LOCK;
|
||||
|
||||
public class WebRtcCallActivity extends Activity {
|
||||
public class WebRtcCallActivity extends AppCompatActivity {
|
||||
|
||||
|
||||
private static final String TAG = WebRtcCallActivity.class.getSimpleName();
|
||||
|
||||
@@ -67,8 +80,10 @@ public class WebRtcCallActivity extends Activity {
|
||||
|
||||
public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
|
||||
|
||||
private WebRtcCallScreen callScreen;
|
||||
private boolean enableVideoIfAvailable;
|
||||
private WebRtcCallView callScreen;
|
||||
private TooltipPopup videoTooltip;
|
||||
private WebRtcCallViewModel viewModel;
|
||||
private boolean enableVideoIfAvailable;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
@@ -79,10 +94,12 @@ public class WebRtcCallActivity extends Activity {
|
||||
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
setContentView(R.layout.webrtc_call_activity);
|
||||
getSupportActionBar().hide();
|
||||
|
||||
setVolumeControlStream(AudioManager.STREAM_VOICE_CALL);
|
||||
|
||||
initializeResources();
|
||||
initializeViewModel();
|
||||
|
||||
processIntent(getIntent());
|
||||
|
||||
@@ -90,18 +107,21 @@ public class WebRtcCallActivity extends Activity {
|
||||
getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
Log.i(TAG, "onResume()");
|
||||
super.onResume();
|
||||
initializeScreenshotSecurity();
|
||||
EventBus.getDefault().register(this);
|
||||
|
||||
if (!EventBus.getDefault().isRegistered(this)) {
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewIntent(Intent intent){
|
||||
Log.i(TAG, "onNewIntent");
|
||||
super.onNewIntent(intent);
|
||||
processIntent(intent);
|
||||
}
|
||||
|
||||
@@ -109,6 +129,17 @@ public class WebRtcCallActivity extends Activity {
|
||||
public void onPause() {
|
||||
Log.i(TAG, "onPause");
|
||||
super.onPause();
|
||||
|
||||
if (!isInPipMode()) {
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
Log.i(TAG, "onStop");
|
||||
super.onStop();
|
||||
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
@@ -122,9 +153,32 @@ public class WebRtcCallActivity extends Activity {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onUserLeaveHint() {
|
||||
if (deviceSupportsPipMode()) {
|
||||
PictureInPictureParams params = new PictureInPictureParams.Builder()
|
||||
.setAspectRatio(new Rational(16, 9))
|
||||
.build();
|
||||
setPictureInPictureParams(params);
|
||||
|
||||
//noinspection deprecation
|
||||
enterPictureInPictureMode();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
|
||||
viewModel.setIsInPipMode(isInPictureInPictureMode);
|
||||
}
|
||||
|
||||
private boolean isInPipMode() {
|
||||
return deviceSupportsPipMode() && isInPictureInPictureMode();
|
||||
}
|
||||
|
||||
private void processIntent(@NonNull Intent intent) {
|
||||
if (ANSWER_ACTION.equals(intent.getAction())) {
|
||||
handleAnswerCall();
|
||||
viewModel.setRecipient(EventBus.getDefault().getStickyEvent(WebRtcViewModel.class).getRecipient());
|
||||
handleAnswerWithAudio();
|
||||
} else if (DENY_ACTION.equals(intent.getAction())) {
|
||||
handleDenyCall();
|
||||
} else if (END_CALL_ACTION.equals(intent.getAction())) {
|
||||
@@ -142,26 +196,77 @@ public class WebRtcCallActivity extends Activity {
|
||||
|
||||
private void initializeResources() {
|
||||
callScreen = ViewUtil.findById(this, R.id.callScreen);
|
||||
callScreen.setHangupButtonListener(new HangupButtonListener());
|
||||
callScreen.setIncomingCallActionListener(new IncomingCallActionListener());
|
||||
callScreen.setAudioMuteButtonListener(new AudioMuteButtonListener());
|
||||
callScreen.setVideoMuteButtonListener(new VideoMuteButtonListener());
|
||||
callScreen.setCameraFlipButtonListener(new CameraFlipButtonListener());
|
||||
callScreen.setSpeakerButtonListener(new SpeakerButtonListener());
|
||||
callScreen.setBluetoothButtonListener(new BluetoothButtonListener());
|
||||
callScreen.setControlsListener(new ControlsListener());
|
||||
}
|
||||
|
||||
private void handleSetAudioSpeaker(boolean enabled) {
|
||||
private void initializeViewModel() {
|
||||
viewModel = ViewModelProviders.of(this).get(WebRtcCallViewModel.class);
|
||||
viewModel.setIsInPipMode(isInPipMode());
|
||||
viewModel.getRemoteVideoEnabled().observe(this,callScreen::setRemoteVideoEnabled);
|
||||
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
||||
viewModel.getCameraDirection().observe(this, callScreen::setCameraDirection);
|
||||
viewModel.getLocalRenderState().observe(this, callScreen::setLocalRenderState);
|
||||
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
|
||||
viewModel.getEvents().observe(this, this::handleViewModelEvent);
|
||||
viewModel.getCallTime().observe(this, this::handleCallTime);
|
||||
viewModel.displaySquareCallCard().observe(this, callScreen::showCallCard);
|
||||
}
|
||||
|
||||
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
|
||||
if (isInPipMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case SHOW_VIDEO_TOOLTIP:
|
||||
if (videoTooltip == null) {
|
||||
videoTooltip = TooltipPopup.forTarget(callScreen.getVideoTooltipTarget())
|
||||
.setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine))
|
||||
.setTextColor(ContextCompat.getColor(this, R.color.core_white))
|
||||
.setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video)
|
||||
.setOnDismissListener(() -> viewModel.onDismissedVideoTooltip())
|
||||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case DISMISS_VIDEO_TOOLTIP:
|
||||
if (videoTooltip != null) {
|
||||
videoTooltip.dismiss();
|
||||
videoTooltip = null;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown event: " + event);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCallTime(long callTime) {
|
||||
EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(callTime);
|
||||
|
||||
if (ellapsedTimeFormatter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString()));
|
||||
}
|
||||
|
||||
private void handleSetAudioHandset() {
|
||||
Intent intent = new Intent(this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_SPEAKER);
|
||||
intent.putExtra(WebRtcCallService.EXTRA_SPEAKER, enabled);
|
||||
startService(intent);
|
||||
}
|
||||
|
||||
private void handleSetAudioBluetooth(boolean enabled) {
|
||||
private void handleSetAudioSpeaker() {
|
||||
Intent intent = new Intent(this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_SPEAKER);
|
||||
intent.putExtra(WebRtcCallService.EXTRA_SPEAKER, true);
|
||||
startService(intent);
|
||||
}
|
||||
|
||||
private void handleSetAudioBluetooth() {
|
||||
Intent intent = new Intent(this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_BLUETOOTH);
|
||||
intent.putExtra(WebRtcCallService.EXTRA_BLUETOOTH, enabled);
|
||||
intent.putExtra(WebRtcCallService.EXTRA_BLUETOOTH, true);
|
||||
startService(intent);
|
||||
}
|
||||
|
||||
@@ -173,10 +278,24 @@ public class WebRtcCallActivity extends Activity {
|
||||
}
|
||||
|
||||
private void handleSetMuteVideo(boolean muted) {
|
||||
Intent intent = new Intent(this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_SET_ENABLE_VIDEO);
|
||||
intent.putExtra(WebRtcCallService.EXTRA_ENABLE, !muted);
|
||||
startService(intent);
|
||||
Recipient recipient = viewModel.getRecipient().get();
|
||||
|
||||
if (!recipient.equals(Recipient.UNKNOWN)) {
|
||||
String recipientDisplayName = recipient.getDisplayName(this);
|
||||
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.CAMERA)
|
||||
.ifNecessary()
|
||||
.withRationaleDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName), R.drawable.ic_video_solid_24_tinted)
|
||||
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName))
|
||||
.onAllGranted(() -> {
|
||||
Intent intent = new Intent(this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_SET_ENABLE_VIDEO);
|
||||
intent.putExtra(WebRtcCallService.EXTRA_ENABLE, !muted);
|
||||
startService(intent);
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFlipCamera() {
|
||||
@@ -185,18 +304,19 @@ public class WebRtcCallActivity extends Activity {
|
||||
startService(intent);
|
||||
}
|
||||
|
||||
private void handleAnswerCall() {
|
||||
WebRtcViewModel event = EventBus.getDefault().getStickyEvent(WebRtcViewModel.class);
|
||||
private void handleAnswerWithAudio() {
|
||||
Recipient recipient = viewModel.getRecipient().get();
|
||||
|
||||
if (event != null) {
|
||||
if (!recipient.equals(Recipient.UNKNOWN)) {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
|
||||
.request(Manifest.permission.RECORD_AUDIO)
|
||||
.ifNecessary()
|
||||
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, event.getRecipient().toShortString(this)),
|
||||
R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
|
||||
.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)
|
||||
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
|
||||
.onAllGranted(() -> {
|
||||
callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_answering), event.getLocalRenderer());
|
||||
callScreen.setRecipient(recipient);
|
||||
callScreen.setStatus(getString(R.string.RedPhone_answering));
|
||||
|
||||
Intent intent = new Intent(this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_ACCEPT_CALL);
|
||||
@@ -207,15 +327,42 @@ public class WebRtcCallActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDenyCall() {
|
||||
WebRtcViewModel event = EventBus.getDefault().getStickyEvent(WebRtcViewModel.class);
|
||||
private void handleAnswerWithVideo() {
|
||||
Recipient recipient = viewModel.getRecipient().get();
|
||||
|
||||
if (event != null) {
|
||||
if (!recipient.equals(Recipient.UNKNOWN)) {
|
||||
Permissions.with(this)
|
||||
.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)
|
||||
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
|
||||
.onAllGranted(() -> {
|
||||
callScreen.setRecipient(recipient);
|
||||
callScreen.setStatus(getString(R.string.RedPhone_answering));
|
||||
|
||||
Intent intent = new Intent(this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_ACCEPT_CALL);
|
||||
intent.putExtra(WebRtcCallService.EXTRA_ANSWER_WITH_VIDEO, true);
|
||||
startService(intent);
|
||||
|
||||
handleSetMuteVideo(false);
|
||||
})
|
||||
.onAnyDenied(this::handleDenyCall)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDenyCall() {
|
||||
Recipient recipient = viewModel.getRecipient().get();
|
||||
|
||||
if (!recipient.equals(Recipient.UNKNOWN)) {
|
||||
Intent intent = new Intent(this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_DENY_CALL);
|
||||
startService(intent);
|
||||
|
||||
callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_ending_call), event.getLocalRenderer());
|
||||
callScreen.setRecipient(recipient);
|
||||
callScreen.setStatus(getString(R.string.RedPhone_ending_call));
|
||||
delayedFinish();
|
||||
}
|
||||
}
|
||||
@@ -228,46 +375,54 @@ public class WebRtcCallActivity extends Activity {
|
||||
}
|
||||
|
||||
private void handleIncomingCall(@NonNull WebRtcViewModel event) {
|
||||
callScreen.setIncomingCall(event.getRecipient());
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
}
|
||||
|
||||
private void handleOutgoingCall(@NonNull WebRtcViewModel event) {
|
||||
callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_dialing), event.getLocalRenderer());
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
callScreen.setStatus(getString(R.string.WebRtcCallActivity__calling));
|
||||
}
|
||||
|
||||
private void handleTerminate(@NonNull Recipient recipient, @NonNull SurfaceViewRenderer localRenderer /*, int terminationType */) {
|
||||
Log.i(TAG, "handleTerminate called");
|
||||
private void handleTerminate(@NonNull Recipient recipient, @NonNull HangupMessage.Type hangupType) {
|
||||
Log.i(TAG, "handleTerminate called: " + hangupType.name());
|
||||
|
||||
callScreen.setRecipient(recipient);
|
||||
callScreen.setStatusFromHangupType(hangupType);
|
||||
|
||||
callScreen.setActiveCall(recipient, getString(R.string.RedPhone_ending_call), localRenderer);
|
||||
EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
|
||||
|
||||
delayedFinish();
|
||||
}
|
||||
|
||||
private void handleCallRinging(@NonNull WebRtcViewModel event) {
|
||||
callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_ringing), event.getLocalRenderer());
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
callScreen.setStatus(getString(R.string.RedPhone_ringing));
|
||||
}
|
||||
|
||||
private void handleCallBusy(@NonNull WebRtcViewModel event) {
|
||||
callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_busy), event.getLocalRenderer());
|
||||
EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
callScreen.setStatus(getString(R.string.RedPhone_busy));
|
||||
|
||||
delayedFinish(BUSY_SIGNAL_DELAY_FINISH);
|
||||
}
|
||||
|
||||
private void handleCallConnected(@NonNull WebRtcViewModel event) {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES);
|
||||
callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_connected), "", event.getLocalRenderer(), event.getRemoteRenderer());
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
}
|
||||
|
||||
private void handleRecipientUnavailable(@NonNull WebRtcViewModel event) {
|
||||
callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_recipient_unavailable), event.getLocalRenderer());
|
||||
EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable));
|
||||
delayedFinish();
|
||||
}
|
||||
|
||||
private void handleServerFailure(@NonNull WebRtcViewModel event) {
|
||||
callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_network_failed), event.getLocalRenderer());
|
||||
EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
callScreen.setStatus(getString(R.string.RedPhone_network_failed));
|
||||
delayedFinish();
|
||||
}
|
||||
|
||||
@@ -281,44 +436,63 @@ public class WebRtcCallActivity extends Activity {
|
||||
dialog.setPositiveButton(R.string.RedPhone_got_it, new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
WebRtcCallActivity.this.handleTerminate(event.getRecipient(), event.getLocalRenderer());
|
||||
WebRtcCallActivity.this.handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL);
|
||||
}
|
||||
});
|
||||
dialog.setOnCancelListener(new DialogInterface.OnCancelListener() {
|
||||
@Override
|
||||
public void onCancel(DialogInterface dialog) {
|
||||
WebRtcCallActivity.this.handleTerminate(event.getRecipient(), event.getLocalRenderer());
|
||||
WebRtcCallActivity.this.handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL);
|
||||
}
|
||||
});
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) {
|
||||
final IdentityKey theirIdentity = event.getIdentityKey();
|
||||
final Recipient recipient = event.getRecipient();
|
||||
final IdentityKey theirKey = event.getIdentityKey();
|
||||
final Recipient recipient = event.getRecipient();
|
||||
|
||||
callScreen.setUntrustedIdentity(recipient, theirIdentity);
|
||||
callScreen.setAcceptIdentityListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
synchronized (SESSION_LOCK) {
|
||||
TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(WebRtcCallActivity.this);
|
||||
identityKeyStore.saveIdentity(new SignalProtocolAddress(recipient.requireServiceId(), 1), theirIdentity, true);
|
||||
}
|
||||
if (theirKey == null) {
|
||||
handleTerminate(recipient, HangupMessage.Type.NORMAL);
|
||||
}
|
||||
|
||||
Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL)
|
||||
.putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId()));
|
||||
startService(intent);
|
||||
}
|
||||
});
|
||||
String name = recipient.getDisplayName(this);
|
||||
String introduction = getString(R.string.WebRtcCallScreen_new_safety_numbers, name, name);
|
||||
SpannableString spannableString = new SpannableString(introduction + " " + getString(R.string.WebRtcCallScreen_you_may_wish_to_verify_this_contact));
|
||||
|
||||
callScreen.setCancelIdentityButton(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
handleTerminate(recipient, event.getLocalRenderer());
|
||||
}
|
||||
});
|
||||
spannableString.setSpan(new VerifySpan(this, recipient.getId(), theirKey), introduction.length() + 1, spannableString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
AppCompatTextView untrustedIdentityExplanation = new AppCompatTextView(this);
|
||||
untrustedIdentityExplanation.setText(spannableString);
|
||||
untrustedIdentityExplanation.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setView(untrustedIdentityExplanation)
|
||||
.setPositiveButton(R.string.WebRtcCallScreen_accept, (d, w) -> {
|
||||
synchronized (SESSION_LOCK) {
|
||||
TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(WebRtcCallActivity.this);
|
||||
identityKeyStore.saveIdentity(new SignalProtocolAddress(recipient.requireServiceId(), 1), theirKey, true);
|
||||
}
|
||||
|
||||
d.dismiss();
|
||||
|
||||
Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL)
|
||||
.putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId()));
|
||||
|
||||
startService(intent);
|
||||
})
|
||||
.setNegativeButton(R.string.WebRtcCallScreen_end_call, (d, w) -> {
|
||||
d.dismiss();
|
||||
handleTerminate(recipient, HangupMessage.Type.NORMAL);
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private boolean deviceSupportsPipMode() {
|
||||
return Build.VERSION.SDK_INT >= 26 &&
|
||||
FeatureFlags.callingPip() &&
|
||||
getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
|
||||
}
|
||||
|
||||
private void delayedFinish() {
|
||||
@@ -326,34 +500,35 @@ public class WebRtcCallActivity extends Activity {
|
||||
}
|
||||
|
||||
private void delayedFinish(int delayMillis) {
|
||||
callScreen.postDelayed(new Runnable() {
|
||||
public void run() {
|
||||
WebRtcCallActivity.this.finish();
|
||||
}
|
||||
}, delayMillis);
|
||||
callScreen.postDelayed(WebRtcCallActivity.this::finish, delayMillis);
|
||||
}
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventMainThread(final WebRtcViewModel event) {
|
||||
Log.i(TAG, "Got message from service: " + event);
|
||||
|
||||
viewModel.setRecipient(event.getRecipient());
|
||||
|
||||
switch (event.getState()) {
|
||||
case CALL_CONNECTED: handleCallConnected(event); break;
|
||||
case NETWORK_FAILURE: handleServerFailure(event); break;
|
||||
case CALL_RINGING: handleCallRinging(event); break;
|
||||
case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), event.getLocalRenderer()); break;
|
||||
case NO_SUCH_USER: handleNoSuchUser(event); break;
|
||||
case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(event); break;
|
||||
case CALL_INCOMING: handleIncomingCall(event); break;
|
||||
case CALL_OUTGOING: handleOutgoingCall(event); break;
|
||||
case CALL_BUSY: handleCallBusy(event); break;
|
||||
case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break;
|
||||
case CALL_CONNECTED: handleCallConnected(event); break;
|
||||
case NETWORK_FAILURE: handleServerFailure(event); break;
|
||||
case CALL_RINGING: handleCallRinging(event); 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 NO_SUCH_USER: handleNoSuchUser(event); break;
|
||||
case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(event); break;
|
||||
case CALL_INCOMING: handleIncomingCall(event); break;
|
||||
case CALL_OUTGOING: handleOutgoingCall(event); break;
|
||||
case CALL_BUSY: handleCallBusy(event); break;
|
||||
case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break;
|
||||
}
|
||||
|
||||
callScreen.setRemoteVideoEnabled(event.isRemoteVideoEnabled());
|
||||
callScreen.updateAudioState(event.isBluetoothAvailable(), event.isMicrophoneEnabled());
|
||||
callScreen.setControlsEnabled(event.getState() != WebRtcViewModel.State.CALL_INCOMING);
|
||||
callScreen.setLocalVideoState(event.getLocalCameraState(), event.getLocalRenderer());
|
||||
callScreen.setLocalRenderer(event.getLocalRenderer());
|
||||
callScreen.setRemoteRenderer(event.getRemoteRenderer());
|
||||
|
||||
viewModel.updateFromWebRtcViewModel(event);
|
||||
|
||||
if (event.getLocalCameraState().getCameraCount() > 0 && enableVideoIfAvailable) {
|
||||
enableVideoIfAvailable = false;
|
||||
@@ -361,56 +536,74 @@ public class WebRtcCallActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
private class HangupButtonListener implements WebRtcCallScreen.HangupButtonListener {
|
||||
public void onClick() {
|
||||
private final class ControlsListener implements WebRtcCallView.ControlsListener {
|
||||
|
||||
@Override
|
||||
public void onControlsFadeOut() {
|
||||
if (videoTooltip != null) {
|
||||
videoTooltip.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput) {
|
||||
switch (audioOutput) {
|
||||
case HANDSET:
|
||||
handleSetAudioHandset();
|
||||
break;
|
||||
case HEADSET:
|
||||
handleSetAudioBluetooth();
|
||||
break;
|
||||
case SPEAKER:
|
||||
handleSetAudioSpeaker();
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unknown output: " + audioOutput);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVideoChanged(boolean isVideoEnabled) {
|
||||
handleSetMuteVideo(!isVideoEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMicChanged(boolean isMicEnabled) {
|
||||
handleSetMuteAudio(!isMicEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraDirectionChanged() {
|
||||
handleFlipCamera();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEndCallPressed() {
|
||||
handleEndCall();
|
||||
}
|
||||
}
|
||||
|
||||
private class AudioMuteButtonListener implements WebRtcCallControls.MuteButtonListener {
|
||||
@Override
|
||||
public void onToggle(boolean isMuted) {
|
||||
WebRtcCallActivity.this.handleSetMuteAudio(isMuted);
|
||||
}
|
||||
}
|
||||
|
||||
private class VideoMuteButtonListener implements WebRtcCallControls.MuteButtonListener {
|
||||
@Override
|
||||
public void onToggle(boolean isMuted) {
|
||||
WebRtcCallActivity.this.handleSetMuteVideo(isMuted);
|
||||
}
|
||||
}
|
||||
|
||||
private class CameraFlipButtonListener implements WebRtcCallControls.CameraFlipButtonListener {
|
||||
@Override
|
||||
public void onToggle() {
|
||||
WebRtcCallActivity.this.handleFlipCamera();
|
||||
}
|
||||
}
|
||||
|
||||
private class SpeakerButtonListener implements WebRtcCallControls.SpeakerButtonListener {
|
||||
@Override
|
||||
public void onSpeakerChange(boolean isSpeaker) {
|
||||
WebRtcCallActivity.this.handleSetAudioSpeaker(isSpeaker);
|
||||
}
|
||||
}
|
||||
|
||||
private class BluetoothButtonListener implements WebRtcCallControls.BluetoothButtonListener {
|
||||
@Override
|
||||
public void onBluetoothChange(boolean isBluetooth) {
|
||||
WebRtcCallActivity.this.handleSetAudioBluetooth(isBluetooth);
|
||||
}
|
||||
}
|
||||
|
||||
private class IncomingCallActionListener implements WebRtcAnswerDeclineButton.AnswerDeclineListener {
|
||||
@Override
|
||||
public void onAnswered() {
|
||||
WebRtcCallActivity.this.handleAnswerCall();
|
||||
public void onDenyCallPressed() {
|
||||
handleDenyCall();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDeclined() {
|
||||
WebRtcCallActivity.this.handleDenyCall();
|
||||
public void onAcceptCallWithVoiceOnlyPressed() {
|
||||
handleAnswerWithAudio();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAcceptCallPressed() {
|
||||
if (viewModel.isAnswerWithVideoAvailable()) {
|
||||
handleAnswerWithVideo();
|
||||
} else {
|
||||
handleAnswerWithAudio();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDownCaretPressed() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
|
||||
@@ -51,6 +53,9 @@ public abstract class Attachment {
|
||||
@Nullable
|
||||
private final BlurHash blurHash;
|
||||
|
||||
@Nullable
|
||||
private final AudioHash audioHash;
|
||||
|
||||
@NonNull
|
||||
private final TransformProperties transformProperties;
|
||||
|
||||
@@ -58,7 +63,7 @@ public abstract class Attachment {
|
||||
int cdnNumber, @Nullable String location, @Nullable String key, @Nullable String relay,
|
||||
@Nullable byte[] digest, @Nullable String fastPreflightId, boolean voiceNote,
|
||||
int width, int height, boolean quote, long uploadTimestamp, @Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash,
|
||||
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
this.contentType = contentType;
|
||||
@@ -79,6 +84,7 @@ public abstract class Attachment {
|
||||
this.stickerLocator = stickerLocator;
|
||||
this.caption = caption;
|
||||
this.blurHash = blurHash;
|
||||
this.audioHash = audioHash;
|
||||
this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty();
|
||||
}
|
||||
|
||||
@@ -172,6 +178,10 @@ public abstract class Attachment {
|
||||
return blurHash;
|
||||
}
|
||||
|
||||
public @Nullable AudioHash getAudioHash() {
|
||||
return audioHash;
|
||||
}
|
||||
|
||||
public @Nullable String getCaption() {
|
||||
return caption;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
@@ -25,11 +26,11 @@ public class DatabaseAttachment extends Attachment {
|
||||
String fileName, int cdnNumber, String location, String key, String relay,
|
||||
byte[] digest, String fastPreflightId, boolean voiceNote,
|
||||
int width, int height, boolean quote, @Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash,
|
||||
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties, int displayOrder,
|
||||
long uploadTimestamp)
|
||||
{
|
||||
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, transformProperties);
|
||||
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this.attachmentId = attachmentId;
|
||||
this.hasData = hasData;
|
||||
this.hasThumbnail = hasThumbnail;
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
public class MmsNotificationAttachment extends Attachment {
|
||||
|
||||
public MmsNotificationAttachment(int status, long size) {
|
||||
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, false, 0, 0, false, 0, null, null, null, null);
|
||||
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, false, 0, 0, false, 0, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
@@ -24,7 +25,7 @@ public class PointerAttachment extends Attachment {
|
||||
int width, int height, long uploadTimestamp, @Nullable String caption, @Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash)
|
||||
{
|
||||
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null);
|
||||
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
public class TombstoneAttachment extends Attachment {
|
||||
|
||||
public TombstoneAttachment(@NonNull String contentType, boolean quote) {
|
||||
super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, 0, 0, quote, 0, null, null, null, null);
|
||||
super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, 0, 0, quote, 0, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
@@ -16,18 +17,18 @@ public class UriAttachment extends Attachment {
|
||||
|
||||
public UriAttachment(@NonNull Uri uri, @NonNull String contentType, int transferState, long size,
|
||||
@Nullable String fileName, boolean voiceNote, boolean quote, @Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable TransformProperties transformProperties)
|
||||
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable AudioHash audioHash, @Nullable TransformProperties transformProperties)
|
||||
{
|
||||
this(uri, uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, quote, caption, stickerLocator, blurHash, transformProperties);
|
||||
this(uri, uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, quote, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
}
|
||||
|
||||
public UriAttachment(@NonNull Uri dataUri, @Nullable Uri thumbnailUri,
|
||||
@NonNull String contentType, int transferState, long size, int width, int height,
|
||||
@Nullable String fileName, @Nullable String fastPreflightId,
|
||||
boolean voiceNote, boolean quote, @Nullable String caption, @Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash, @Nullable TransformProperties transformProperties)
|
||||
@Nullable BlurHash blurHash, @Nullable AudioHash audioHash, @Nullable TransformProperties transformProperties)
|
||||
{
|
||||
super(contentType, transferState, size, fileName, 0, null, null, null, null, fastPreflightId, voiceNote, width, height, quote, 0, caption, stickerLocator, blurHash, transformProperties);
|
||||
super(contentType, transferState, size, fileName, 0, null, null, null, null, fastPreflightId, voiceNote, width, height, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this.dataUri = dataUri;
|
||||
this.thumbnailUri = thumbnailUri;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.audio;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* An AudioHash is a compact string representation of the wave form and duration for an audio file.
|
||||
*/
|
||||
public final class AudioHash {
|
||||
|
||||
@NonNull private final String hash;
|
||||
@NonNull private final AudioWaveFormData audioWaveForm;
|
||||
|
||||
private AudioHash(@NonNull String hash, @NonNull AudioWaveFormData audioWaveForm) {
|
||||
this.hash = hash;
|
||||
this.audioWaveForm = audioWaveForm;
|
||||
}
|
||||
|
||||
public AudioHash(@NonNull AudioWaveFormData audioWaveForm) {
|
||||
this(Base64.encodeBytes(audioWaveForm.toByteArray()), audioWaveForm);
|
||||
}
|
||||
|
||||
public static @Nullable AudioHash parseOrNull(@Nullable String hash) {
|
||||
if (hash == null) return null;
|
||||
try {
|
||||
return new AudioHash(hash, AudioWaveFormData.parseFrom(Base64.decode(hash)));
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull AudioWaveFormData getAudioWaveForm() {
|
||||
return audioWaveForm;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
AudioHash other = (AudioHash) o;
|
||||
return hash.equals(other.hash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return hash.hashCode();
|
||||
}
|
||||
|
||||
public @NonNull String getHash() {
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package org.thoughtcrime.securesms.audio;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaExtractor;
|
||||
import android.media.MediaFormat;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.LruCache;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
|
||||
import org.thoughtcrime.securesms.media.MediaInput;
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
public final class AudioWaveForm {
|
||||
|
||||
private static final String TAG = Log.tag(AudioWaveForm.class);
|
||||
|
||||
private static final int BAR_COUNT = 46;
|
||||
private static final int SAMPLES_PER_BAR = 4;
|
||||
|
||||
private final Context context;
|
||||
private final AudioSlide slide;
|
||||
|
||||
public AudioWaveForm(@NonNull Context context, @NonNull AudioSlide slide) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.slide = slide;
|
||||
}
|
||||
|
||||
private static final LruCache<String, AudioFileInfo> WAVE_FORM_CACHE = new LruCache<>(200);
|
||||
private static final Executor AUDIO_DECODER_EXECUTOR = new SerialExecutor(SignalExecutors.BOUNDED);
|
||||
|
||||
@AnyThread
|
||||
public void getWaveForm(@NonNull Consumer<AudioFileInfo> onSuccess, @NonNull Runnable onFailure) {
|
||||
Uri uri = slide.getUri();
|
||||
Attachment attachment = slide.asAttachment();
|
||||
|
||||
if (uri == null) {
|
||||
Log.w(TAG, "No uri");
|
||||
Util.runOnMain(onFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(attachment instanceof DatabaseAttachment)) {
|
||||
Log.i(TAG, "Not yet in database");
|
||||
Util.runOnMain(onFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = uri.toString();
|
||||
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
|
||||
if (cached != null) {
|
||||
Log.i(TAG, "Loaded wave form from cache " + cacheKey);
|
||||
Util.runOnMain(() -> onSuccess.accept(cached));
|
||||
return;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_EXECUTOR.execute(() -> {
|
||||
AudioFileInfo cachedInExecutor = WAVE_FORM_CACHE.get(cacheKey);
|
||||
if (cachedInExecutor != null) {
|
||||
Log.i(TAG, "Loaded wave form from cache inside executor" + cacheKey);
|
||||
Util.runOnMain(() -> onSuccess.accept(cachedInExecutor));
|
||||
return;
|
||||
}
|
||||
|
||||
AudioHash audioHash = attachment.getAudioHash();
|
||||
if (audioHash != null) {
|
||||
AudioFileInfo audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioHash.getAudioWaveForm());
|
||||
if (audioFileInfo.waveForm.length != BAR_COUNT) {
|
||||
Log.w(TAG, "Wave form from database does not match bar count, regenerating " + cacheKey);
|
||||
} else {
|
||||
WAVE_FORM_CACHE.put(cacheKey, audioFileInfo);
|
||||
Log.i(TAG, "Loaded wave form from DB " + cacheKey);
|
||||
Util.runOnMain(() -> onSuccess.accept(audioFileInfo));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||
long startTime = System.currentTimeMillis();
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
|
||||
DatabaseFactory.getAttachmentDatabase(context).writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
||||
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
Util.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
Util.runOnMain(onFailure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on decode sample from:
|
||||
* <p>
|
||||
* https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
|
||||
*/
|
||||
@WorkerThread
|
||||
@RequiresApi(api = 23)
|
||||
private @NonNull AudioFileInfo generateWaveForm(@NonNull Uri uri) throws IOException {
|
||||
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
|
||||
long[] wave = new long[BAR_COUNT];
|
||||
int[] waveSamples = new int[BAR_COUNT];
|
||||
|
||||
MediaExtractor extractor = dataSource.createExtractor();
|
||||
|
||||
if (extractor.getTrackCount() == 0) {
|
||||
throw new IOException("No audio track");
|
||||
}
|
||||
|
||||
MediaFormat format = extractor.getTrackFormat(0);
|
||||
|
||||
if (!format.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
throw new IOException("Unknown duration");
|
||||
}
|
||||
|
||||
long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION);
|
||||
String mime = format.getString(MediaFormat.KEY_MIME);
|
||||
|
||||
if (!mime.startsWith("audio/")) {
|
||||
throw new IOException("Mime not audio");
|
||||
}
|
||||
|
||||
MediaCodec codec = MediaCodec.createDecoderByType(mime);
|
||||
|
||||
if (totalDurationUs == 0) {
|
||||
throw new IOException("Zero duration");
|
||||
}
|
||||
|
||||
codec.configure(format, null, null, 0);
|
||||
codec.start();
|
||||
|
||||
ByteBuffer[] codecInputBuffers = codec.getInputBuffers();
|
||||
ByteBuffer[] codecOutputBuffers = codec.getOutputBuffers();
|
||||
|
||||
extractor.selectTrack(0);
|
||||
|
||||
long kTimeOutUs = 5000;
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean sawInputEOS = false;
|
||||
boolean sawOutputEOS = false;
|
||||
int noOutputCounter = 0;
|
||||
|
||||
while (!sawOutputEOS && noOutputCounter < 50) {
|
||||
noOutputCounter++;
|
||||
if (!sawInputEOS) {
|
||||
int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
|
||||
if (inputBufIndex >= 0) {
|
||||
ByteBuffer dstBuf = codecInputBuffers[inputBufIndex];
|
||||
int sampleSize = extractor.readSampleData(dstBuf, 0);
|
||||
long presentationTimeUs = 0;
|
||||
|
||||
if (sampleSize < 0) {
|
||||
sawInputEOS = true;
|
||||
sampleSize = 0;
|
||||
} else {
|
||||
presentationTimeUs = extractor.getSampleTime();
|
||||
}
|
||||
|
||||
codec.queueInputBuffer(
|
||||
inputBufIndex,
|
||||
0,
|
||||
sampleSize,
|
||||
presentationTimeUs,
|
||||
sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
|
||||
|
||||
if (!sawInputEOS) {
|
||||
int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
sawInputEOS = !extractor.advance();
|
||||
int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
|
||||
sawInputEOS = !extractor.advance();
|
||||
if (!sawInputEOS) {
|
||||
nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int outputBufferIndex;
|
||||
do {
|
||||
outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs);
|
||||
if (outputBufferIndex >= 0) {
|
||||
if (info.size > 0) {
|
||||
noOutputCounter = 0;
|
||||
}
|
||||
|
||||
ByteBuffer buf = codecOutputBuffers[outputBufferIndex];
|
||||
int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
|
||||
long total = 0;
|
||||
for (int i = 0; i < info.size; i += 2 * 4) {
|
||||
short aShort = buf.getShort(i);
|
||||
total += Math.abs(aShort);
|
||||
}
|
||||
if (barIndex >= 0 && barIndex < wave.length) {
|
||||
wave[barIndex] += total;
|
||||
waveSamples[barIndex] += info.size / 2;
|
||||
}
|
||||
codec.releaseOutputBuffer(outputBufferIndex, false);
|
||||
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
||||
sawOutputEOS = true;
|
||||
}
|
||||
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
|
||||
codecOutputBuffers = codec.getOutputBuffers();
|
||||
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
Log.d(TAG, "output format has changed to " + codec.getOutputFormat());
|
||||
}
|
||||
} while (outputBufferIndex >= 0);
|
||||
}
|
||||
|
||||
codec.stop();
|
||||
codec.release();
|
||||
extractor.release();
|
||||
|
||||
float[] floats = new float[BAR_COUNT];
|
||||
byte[] bytes = new byte[BAR_COUNT];
|
||||
float max = 0;
|
||||
|
||||
for (int i = 0; i < BAR_COUNT; i++) {
|
||||
if (waveSamples[i] == 0) continue;
|
||||
|
||||
floats[i] = wave[i] / (float) waveSamples[i];
|
||||
if (floats[i] > max) {
|
||||
max = floats[i];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < BAR_COUNT; i++) {
|
||||
float normalized = floats[i] / max;
|
||||
bytes[i] = (byte) (255 * normalized);
|
||||
}
|
||||
|
||||
return new AudioFileInfo(totalDurationUs, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public static class AudioFileInfo {
|
||||
private final long durationUs;
|
||||
private final byte[] waveFormBytes;
|
||||
private final float[] waveForm;
|
||||
|
||||
private static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
|
||||
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
|
||||
}
|
||||
|
||||
private AudioFileInfo(long durationUs, byte[] waveFormBytes) {
|
||||
this.durationUs = durationUs;
|
||||
this.waveFormBytes = waveFormBytes;
|
||||
this.waveForm = new float[waveFormBytes.length];
|
||||
|
||||
for (int i = 0; i < waveFormBytes.length; i++) {
|
||||
int unsigned = waveFormBytes[i] & 0xff;
|
||||
this.waveForm[i] = unsigned / 255f;
|
||||
}
|
||||
}
|
||||
|
||||
public long getDuration(@NonNull TimeUnit timeUnit) {
|
||||
return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS);
|
||||
}
|
||||
|
||||
public float[] getWaveForm() {
|
||||
return waveForm;
|
||||
}
|
||||
|
||||
private @NonNull AudioWaveFormData toDatabaseProtobuf() {
|
||||
return AudioWaveFormData.newBuilder()
|
||||
.setDurationUs(durationUs)
|
||||
.setWaveForm(ByteString.copyFrom(waveFormBytes))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.color;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
@@ -2,20 +2,14 @@ package org.thoughtcrime.securesms.components;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.ToggleButton;
|
||||
|
||||
public class AccessibleToggleButton extends ToggleButton {
|
||||
import androidx.appcompat.widget.AppCompatToggleButton;
|
||||
|
||||
public class AccessibleToggleButton extends AppCompatToggleButton {
|
||||
|
||||
private OnCheckedChangeListener listener;
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
|
||||
public AccessibleToggleButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
}
|
||||
|
||||
public AccessibleToggleButton(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.SeekBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
|
||||
import org.thoughtcrime.securesms.audio.AudioWaveForm;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
@@ -36,7 +37,7 @@ import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class AudioView extends FrameLayout implements AudioSlidePlayer.Listener {
|
||||
@@ -47,7 +48,6 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||
private static final int REVERSE = -1;
|
||||
|
||||
@NonNull private final AnimatingToggle controlToggle;
|
||||
@NonNull private final ViewGroup container;
|
||||
@NonNull private final View progressAndPlay;
|
||||
@NonNull private final LottieAnimationView playPauseButton;
|
||||
@NonNull private final ImageView downloadButton;
|
||||
@@ -56,13 +56,17 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||
private final boolean smallView;
|
||||
private final boolean autoRewind;
|
||||
|
||||
@Nullable private final TextView timestamp;
|
||||
@Nullable private final TextView duration;
|
||||
|
||||
@ColorInt private final int waveFormPlayedBarsColor;
|
||||
@ColorInt private final int waveFormUnplayedBarsColor;
|
||||
|
||||
@Nullable private SlideClickListener downloadListener;
|
||||
@Nullable private AudioSlidePlayer audioSlidePlayer;
|
||||
private int backwardsCounter;
|
||||
private int lottieDirection;
|
||||
private boolean isPlaying;
|
||||
private long durationMillis;
|
||||
|
||||
public AudioView(Context context) {
|
||||
this(context, null);
|
||||
@@ -83,22 +87,22 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||
|
||||
inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this);
|
||||
|
||||
this.container = findViewById(R.id.audio_widget_container);
|
||||
this.controlToggle = findViewById(R.id.control_toggle);
|
||||
this.playPauseButton = findViewById(R.id.play);
|
||||
this.progressAndPlay = findViewById(R.id.progress_and_play);
|
||||
this.downloadButton = findViewById(R.id.download);
|
||||
this.circleProgress = findViewById(R.id.circle_progress);
|
||||
this.seekBar = findViewById(R.id.seek);
|
||||
this.timestamp = findViewById(R.id.timestamp);
|
||||
this.duration = findViewById(R.id.duration);
|
||||
|
||||
lottieDirection = REVERSE;
|
||||
this.playPauseButton.setOnClickListener(new PlayPauseClickedListener());
|
||||
this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener());
|
||||
|
||||
setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE),
|
||||
typedArray.getColor(R.styleable.AudioView_backgroundTintColor, Color.WHITE));
|
||||
container.setBackgroundColor(typedArray.getColor(R.styleable.AudioView_widgetBackground, Color.TRANSPARENT));
|
||||
setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE));
|
||||
|
||||
this.waveFormPlayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformPlayedBarsColor, Color.WHITE);
|
||||
this.waveFormUnplayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformUnplayedBarsColor, Color.WHITE);
|
||||
} finally {
|
||||
if (typedArray != null) {
|
||||
typedArray.recycle();
|
||||
@@ -121,6 +125,14 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||
public void setAudio(final @NonNull AudioSlide audio,
|
||||
final boolean showControls)
|
||||
{
|
||||
if (seekBar instanceof WaveFormSeekBarView) {
|
||||
if (audioSlidePlayer != null && !Objects.equals(audioSlidePlayer.getAudioSlide().getUri(), audio.getUri())) {
|
||||
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
|
||||
waveFormView.setWaveMode(false);
|
||||
seekBar.setProgress(0);
|
||||
durationMillis = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (showControls && audio.isPendingDownload()) {
|
||||
controlToggle.displayQuick(downloadButton);
|
||||
@@ -141,6 +153,28 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||
}
|
||||
|
||||
this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this);
|
||||
|
||||
if (seekBar instanceof WaveFormSeekBarView) {
|
||||
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
|
||||
waveFormView.setColors(waveFormPlayedBarsColor, waveFormUnplayedBarsColor);
|
||||
if (android.os.Build.VERSION.SDK_INT >= 23) {
|
||||
new AudioWaveForm(getContext(), audio).getWaveForm(
|
||||
data -> {
|
||||
if (duration != null) {
|
||||
durationMillis = data.getDuration(TimeUnit.MILLISECONDS);
|
||||
updateProgress(0, 0);
|
||||
duration.setVisibility(VISIBLE);
|
||||
}
|
||||
waveFormView.setWaveData(data.getWaveForm());
|
||||
},
|
||||
() -> waveFormView.setWaveMode(false));
|
||||
} else {
|
||||
waveFormView.setWaveMode(false);
|
||||
if (duration != null) {
|
||||
duration.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void cleanup() {
|
||||
@@ -210,10 +244,9 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||
}
|
||||
|
||||
private void updateProgress(float progress, long millis) {
|
||||
if (timestamp != null) {
|
||||
timestamp.setText(String.format(Locale.getDefault(), "%02d:%02d",
|
||||
TimeUnit.MILLISECONDS.toMinutes(millis),
|
||||
TimeUnit.MILLISECONDS.toSeconds(millis)));
|
||||
if (duration != null && durationMillis > 0) {
|
||||
long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis);
|
||||
duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
|
||||
}
|
||||
|
||||
if (smallView) {
|
||||
@@ -221,7 +254,7 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||
}
|
||||
}
|
||||
|
||||
public void setTint(int foregroundTint, int backgroundTint) {
|
||||
public void setTint(int foregroundTint) {
|
||||
post(()-> this.playPauseButton.addValueCallback(new KeyPath("**"),
|
||||
LottieProperty.COLOR_FILTER,
|
||||
new LottieValueCallback<>(new SimpleColorFilter(foregroundTint))));
|
||||
@@ -229,8 +262,8 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||
this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
this.circleProgress.setBarColor(foregroundTint);
|
||||
|
||||
if (this.timestamp != null) {
|
||||
this.timestamp.setTextColor(foregroundTint);
|
||||
if (this.duration != null) {
|
||||
this.duration.setTextColor(foregroundTint);
|
||||
}
|
||||
this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
@@ -336,7 +369,12 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||
private boolean wasPlaying;
|
||||
|
||||
@Override
|
||||
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {}
|
||||
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
|
||||
if (fromUser && durationMillis > 0) {
|
||||
float progressFloat = progress / (float) seekBar.getMax();
|
||||
updateProgress(progressFloat, (long) (durationMillis * progressFloat));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onStartTrackingTouch(SeekBar seekBar) {
|
||||
|
||||
@@ -6,12 +6,12 @@ import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.provider.ContactsContract;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
@@ -21,11 +21,13 @@ import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientExporter;
|
||||
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
import java.util.Objects;
|
||||
@@ -159,14 +161,26 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
}
|
||||
}
|
||||
|
||||
private void setAvatarClickHandler(final Recipient recipient, boolean quickContactEnabled) {
|
||||
super.setOnClickListener(v -> {
|
||||
if (quickContactEnabled) {
|
||||
getContext().startActivity(RecipientPreferenceActivity.getLaunchIntent(getContext(), recipient.getId()));
|
||||
} else if (listener != null) {
|
||||
listener.onClick(v);
|
||||
}
|
||||
});
|
||||
private void setAvatarClickHandler(@NonNull final Recipient recipient, boolean quickContactEnabled) {
|
||||
if (quickContactEnabled) {
|
||||
super.setOnClickListener(v -> {
|
||||
Context context = getContext();
|
||||
if (FeatureFlags.newGroupUI() && recipient.isPushGroup()) {
|
||||
context.startActivity(ManageGroupActivity.newIntent(context, recipient.requireGroupId().requirePush()),
|
||||
ManageGroupActivity.createTransitionBundle(context, this));
|
||||
} else {
|
||||
if (context instanceof FragmentActivity) {
|
||||
RecipientBottomSheetDialogFragment.create(recipient.getId(), null)
|
||||
.show(((FragmentActivity) context).getSupportFragmentManager(), "BOTTOM");
|
||||
} else {
|
||||
context.startActivity(RecipientPreferenceActivity.getLaunchIntent(context, recipient.getId()));
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
super.setOnClickListener(listener);
|
||||
setClickable(listener != null);
|
||||
}
|
||||
}
|
||||
|
||||
private static class RecipientContactPhoto {
|
||||
|
||||
@@ -3,11 +3,6 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Rect;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.text.TextWatcher;
|
||||
@@ -18,19 +13,23 @@ import android.widget.EditText;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.views.DarkOverflowToolbar;
|
||||
|
||||
public class ContactFilterToolbar extends Toolbar {
|
||||
public final class ContactFilterToolbar extends DarkOverflowToolbar {
|
||||
private OnFilterChangedListener listener;
|
||||
|
||||
private EditText searchText;
|
||||
private AnimatingToggle toggle;
|
||||
private ImageView keyboardToggle;
|
||||
private ImageView dialpadToggle;
|
||||
private ImageView clearToggle;
|
||||
private LinearLayout toggleContainer;
|
||||
private final EditText searchText;
|
||||
private final AnimatingToggle toggle;
|
||||
private final ImageView keyboardToggle;
|
||||
private final ImageView dialpadToggle;
|
||||
private final ImageView clearToggle;
|
||||
private final LinearLayout toggleContainer;
|
||||
|
||||
public ContactFilterToolbar(Context context) {
|
||||
this(context, null);
|
||||
@@ -44,12 +43,12 @@ public class ContactFilterToolbar extends Toolbar {
|
||||
super(context, attrs, defStyleAttr);
|
||||
inflate(context, R.layout.contact_filter_toolbar, this);
|
||||
|
||||
this.searchText = ViewUtil.findById(this, R.id.search_view);
|
||||
this.toggle = ViewUtil.findById(this, R.id.button_toggle);
|
||||
this.keyboardToggle = ViewUtil.findById(this, R.id.search_keyboard);
|
||||
this.dialpadToggle = ViewUtil.findById(this, R.id.search_dialpad);
|
||||
this.clearToggle = ViewUtil.findById(this, R.id.search_clear);
|
||||
this.toggleContainer = ViewUtil.findById(this, R.id.toggle_container);
|
||||
this.searchText = findViewById(R.id.search_view);
|
||||
this.toggle = findViewById(R.id.button_toggle);
|
||||
this.keyboardToggle = findViewById(R.id.search_keyboard);
|
||||
this.dialpadToggle = findViewById(R.id.search_dialpad);
|
||||
this.clearToggle = findViewById(R.id.search_clear);
|
||||
this.toggleContainer = findViewById(R.id.toggle_container);
|
||||
|
||||
this.keyboardToggle.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
@@ -102,11 +101,11 @@ public class ContactFilterToolbar extends Toolbar {
|
||||
setLogo(null);
|
||||
setContentInsetStartWithNavigation(0);
|
||||
expandTapArea(toggleContainer, dialpadToggle);
|
||||
styleSearchText(searchText, context, attrs, defStyleAttr);
|
||||
applyAttributes(searchText, context, attrs, defStyleAttr);
|
||||
searchText.requestFocus();
|
||||
}
|
||||
|
||||
private void styleSearchText(@NonNull EditText searchText,
|
||||
private void applyAttributes(@NonNull EditText searchText,
|
||||
@NonNull Context context,
|
||||
@NonNull AttributeSet attrs,
|
||||
int defStyle)
|
||||
@@ -120,6 +119,9 @@ public class ContactFilterToolbar extends Toolbar {
|
||||
if (styleResource != -1) {
|
||||
TextViewCompat.setTextAppearance(searchText, styleResource);
|
||||
}
|
||||
if (!attributes.getBoolean(R.styleable.ContactFilterToolbar_showDialpad, true)) {
|
||||
dialpadToggle.setVisibility(GONE);
|
||||
}
|
||||
attributes.recycle();
|
||||
}
|
||||
|
||||
@@ -132,6 +134,10 @@ public class ContactFilterToolbar extends Toolbar {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void setHint(@StringRes int hint) {
|
||||
searchText.setHint(hint);
|
||||
}
|
||||
|
||||
private void notifyListener() {
|
||||
if (listener != null) listener.onFilterChanged(searchText.getText().toString());
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ public class InputPanel extends LinearLayout
|
||||
|
||||
private static final String TAG = InputPanel.class.getSimpleName();
|
||||
|
||||
private static final int FADE_TIME = 150;
|
||||
private static final long QUOTE_REVEAL_DURATION_MILLIS = 150;
|
||||
private static final int FADE_TIME = 150;
|
||||
|
||||
private RecyclerView stickerSuggestion;
|
||||
private QuoteView quoteView;
|
||||
@@ -209,7 +210,8 @@ public class InputPanel extends LinearLayout
|
||||
int finalHeight,
|
||||
@Nullable AnimationCompleteListener onAnimationComplete)
|
||||
{
|
||||
ValueAnimator animator = ValueAnimator.ofInt(originalHeight, finalHeight);
|
||||
ValueAnimator animator = ValueAnimator.ofInt(originalHeight, finalHeight)
|
||||
.setDuration(QUOTE_REVEAL_DURATION_MILLIS);
|
||||
|
||||
animator.addUpdateListener(animation -> {
|
||||
ViewGroup.LayoutParams params = view.getLayoutParams();
|
||||
|
||||
@@ -90,9 +90,9 @@ public final class RecyclerViewFastScroller extends LinearLayout {
|
||||
final int action = event.getAction();
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
if (event.getX() < ViewUtil.getX(handle) - handle.getPaddingLeft() ||
|
||||
event.getY() < ViewUtil.getY(handle) - handle.getPaddingTop() ||
|
||||
event.getY() > ViewUtil.getY(handle) + handle.getHeight() + handle.getPaddingBottom())
|
||||
if (event.getX() < handle.getX() - handle.getPaddingLeft() ||
|
||||
event.getY() < handle.getY() - handle.getPaddingTop() ||
|
||||
event.getY() > handle.getY() + handle.getHeight() + handle.getPaddingBottom())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -150,9 +150,9 @@ public final class RecyclerViewFastScroller extends LinearLayout {
|
||||
if (recyclerView != null) {
|
||||
final int itemCount = recyclerView.getAdapter().getItemCount();
|
||||
float proportion;
|
||||
if (ViewUtil.getY(handle) == 0) {
|
||||
if (handle.getY() == 0) {
|
||||
proportion = 0f;
|
||||
} else if (ViewUtil.getY(handle) + handle.getHeight() >= height - TRACK_SNAP_RANGE) {
|
||||
} else if (handle.getY() + handle.getHeight() >= height - TRACK_SNAP_RANGE) {
|
||||
proportion = 1f;
|
||||
} else {
|
||||
proportion = y / (float)height;
|
||||
@@ -169,10 +169,10 @@ public final class RecyclerViewFastScroller extends LinearLayout {
|
||||
final int handleHeight = handle.getHeight();
|
||||
final int bubbleHeight = bubble.getHeight();
|
||||
final int handleY = Util.clamp((int)((height - handleHeight) * y), 0, height - handleHeight);
|
||||
ViewUtil.setY(handle, handleY);
|
||||
ViewUtil.setY(bubble, Util.clamp(handleY - bubbleHeight - bubble.getPaddingBottom() + handleHeight,
|
||||
0,
|
||||
height - bubbleHeight));
|
||||
handle.setY(handleY);
|
||||
bubble.setY(Util.clamp(handleY - bubbleHeight - bubble.getPaddingBottom() + handleHeight,
|
||||
0,
|
||||
height - bubbleHeight));
|
||||
}
|
||||
|
||||
private void showBubble() {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.view.animation.OvershootInterpolator;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.appcompat.widget.AppCompatSeekBar;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public final class WaveFormSeekBarView extends AppCompatSeekBar {
|
||||
|
||||
private static final int ANIM_DURATION = 450;
|
||||
private static final int ANIM_BAR_OFF_SET_DURATION = 12;
|
||||
|
||||
private final Interpolator overshoot = new OvershootInterpolator();
|
||||
private final Paint paint = new Paint();
|
||||
private float[] data = new float[0];
|
||||
private long dataSetTime;
|
||||
private Drawable progressDrawable;
|
||||
private boolean waveMode;
|
||||
|
||||
@ColorInt private int playedBarColor = 0xffffffff;
|
||||
@ColorInt private int unplayedBarColor = 0x7fffffff;
|
||||
@Px private int barWidth;
|
||||
|
||||
public WaveFormSeekBarView(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public WaveFormSeekBarView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public WaveFormSeekBarView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
setWillNotDraw(false);
|
||||
|
||||
paint.setStrokeCap(Paint.Cap.ROUND);
|
||||
paint.setAntiAlias(true);
|
||||
|
||||
progressDrawable = super.getProgressDrawable();
|
||||
|
||||
if (isInEditMode()) {
|
||||
setWaveData(sinusoidalExampleData());
|
||||
dataSetTime = 0;
|
||||
}
|
||||
|
||||
barWidth = getResources().getDimensionPixelSize(R.dimen.wave_form_bar_width);
|
||||
}
|
||||
|
||||
public void setColors(@ColorInt int playedBarColor, @ColorInt int unplayedBarColor) {
|
||||
this.playedBarColor = playedBarColor;
|
||||
this.unplayedBarColor = unplayedBarColor;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setProgressDrawable(Drawable progressDrawable) {
|
||||
this.progressDrawable = progressDrawable;
|
||||
if (!waveMode) {
|
||||
super.setProgressDrawable(progressDrawable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable getProgressDrawable() {
|
||||
return progressDrawable;
|
||||
}
|
||||
|
||||
public void setWaveData(@NonNull float[] data) {
|
||||
if (!Arrays.equals(data, this.data)) {
|
||||
this.data = data;
|
||||
this.dataSetTime = System.currentTimeMillis();
|
||||
}
|
||||
setWaveMode(data.length > 0);
|
||||
}
|
||||
|
||||
public void setWaveMode(boolean waveMode) {
|
||||
this.waveMode = waveMode;
|
||||
super.setProgressDrawable(this.waveMode ? null : progressDrawable);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (waveMode) {
|
||||
drawWave(canvas);
|
||||
}
|
||||
super.onDraw(canvas);
|
||||
}
|
||||
|
||||
private void drawWave(Canvas canvas) {
|
||||
paint.setStrokeWidth(barWidth);
|
||||
|
||||
int usableHeight = getHeight() - getPaddingTop() - getPaddingBottom();
|
||||
int usableWidth = getWidth() - getPaddingLeft() - getPaddingRight();
|
||||
float midpoint = usableHeight / 2f;
|
||||
float maxHeight = usableHeight / 2f - barWidth;
|
||||
float barGap = (usableWidth - data.length * barWidth) / (float) (data.length - 1);
|
||||
|
||||
boolean hasMoreFrames = false;
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(getPaddingLeft(), getPaddingTop());
|
||||
|
||||
for (int bar = 0; bar < data.length; bar++) {
|
||||
float x = bar * (barWidth + barGap) + barWidth / 2f;
|
||||
float y = data[bar] * maxHeight;
|
||||
float progress = x / usableWidth;
|
||||
|
||||
paint.setColor(progress * getMax() < getProgress() ? playedBarColor : unplayedBarColor);
|
||||
|
||||
long time = System.currentTimeMillis() - bar * ANIM_BAR_OFF_SET_DURATION - dataSetTime;
|
||||
float timeX = Math.max(0, Math.min(1, time / (float) ANIM_DURATION));
|
||||
float interpolatedTime = overshoot.getInterpolation(timeX);
|
||||
float interpolatedY = y * interpolatedTime;
|
||||
|
||||
canvas.drawLine(x, midpoint - interpolatedY, x, midpoint + interpolatedY, paint);
|
||||
|
||||
if (time < ANIM_DURATION) {
|
||||
hasMoreFrames = true;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
|
||||
if (hasMoreFrames) {
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private static float[] sinusoidalExampleData() {
|
||||
float[] data = new float[21];
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
data[i] = (float) Math.sin(i / (float) (data.length - 1) * 2 * Math.PI);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
{
|
||||
private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
|
||||
|
||||
private static final String RECENT_STORAGE_KEY = "pref_recent_emoji2";
|
||||
|
||||
private final Context context;
|
||||
private final List<EmojiPageModel> models;
|
||||
private final RecentEmojiPageModel recentModel;
|
||||
@@ -41,7 +43,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
this.context = context;
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.models = new LinkedList<>();
|
||||
this.recentModel = new RecentEmojiPageModel(context);
|
||||
this.recentModel = new RecentEmojiPageModel(context, RECENT_STORAGE_KEY);
|
||||
this.emojiPagerAdapter = new EmojiPagerAdapter(context, models, new EmojiEventListener() {
|
||||
@Override
|
||||
public void onEmojiSelected(String emoji) {
|
||||
@@ -133,7 +135,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
|
||||
@Override
|
||||
public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) {
|
||||
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener);
|
||||
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, true);
|
||||
page.setModel(pages.get(position));
|
||||
container.addView(page);
|
||||
return page;
|
||||
|
||||
@@ -26,7 +26,8 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
|
||||
public EmojiPageView(@NonNull Context context,
|
||||
@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener)
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations)
|
||||
{
|
||||
super(context);
|
||||
final View view = LayoutInflater.from(getContext()).inflate(R.layout.emoji_grid_layout, this, true);
|
||||
@@ -40,7 +41,8 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
adapter = new EmojiPageViewGridAdapter(EmojiProvider.getInstance(context),
|
||||
popup,
|
||||
emojiSelectionListener,
|
||||
this);
|
||||
this,
|
||||
allowVariations);
|
||||
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
recyclerView.setAdapter(adapter);
|
||||
@@ -83,6 +85,10 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
}
|
||||
}
|
||||
|
||||
public void setRecyclerNestedScrollingEnabled(boolean enabled) {
|
||||
recyclerView.setNestedScrollingEnabled(enabled);
|
||||
}
|
||||
|
||||
private static class ScrollDisabler implements RecyclerView.OnItemTouchListener {
|
||||
@Override
|
||||
public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) {
|
||||
|
||||
@@ -22,17 +22,20 @@ public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageView
|
||||
private final EmojiVariationSelectorPopup popup;
|
||||
private final VariationSelectorListener variationSelectorListener;
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
private final boolean allowVariations;
|
||||
|
||||
public EmojiPageViewGridAdapter(@NonNull EmojiProvider emojiProvider,
|
||||
@NonNull EmojiVariationSelectorPopup popup,
|
||||
@NonNull EmojiEventListener emojiEventListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener)
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations)
|
||||
{
|
||||
this.emojiList = new ArrayList<>();
|
||||
this.emojiProvider = emojiProvider;
|
||||
this.popup = popup;
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
this.allowVariations = allowVariations;
|
||||
|
||||
popup.setOnDismissListener(this);
|
||||
}
|
||||
@@ -65,7 +68,7 @@ public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageView
|
||||
emojiEventListener.onEmojiSelected(emoji.getValue());
|
||||
});
|
||||
|
||||
if (emoji.getVariations().size() > 1) {
|
||||
if (allowVariations && emoji.getVariations().size() > 1) {
|
||||
viewHolder.itemView.setOnLongClickListener(v -> {
|
||||
popup.dismiss();
|
||||
popup.setVariations(emoji.getVariations());
|
||||
|
||||
@@ -2,4 +2,10 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
public final class EmojiStrings {
|
||||
public static final String BUST_IN_SILHOUETTE = "\uD83D\uDC64";
|
||||
public static final String PHOTO = "\uD83D\uDCF7";
|
||||
public static final String VIDEO = "\uD83C\uDFA5";
|
||||
public static final String GIF = "\uD83C\uDFA1";
|
||||
public static final String AUDIO = "\uD83C\uDFA4";
|
||||
public static final String FILE = "\uD83D\uDCCE";
|
||||
public static final String STICKER = "\u2B50";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -36,6 +37,10 @@ public final class EmojiUtil {
|
||||
|
||||
private EmojiUtil() {}
|
||||
|
||||
public static List<EmojiPageModel> getDisplayPages() {
|
||||
return EmojiPages.DISPLAY_PAGES;
|
||||
}
|
||||
|
||||
/**
|
||||
* This will return all ways we know of expressing a singular emoji. This is to aid in search,
|
||||
* where some platforms may send an emoji we've locally marked as 'obsolete'.
|
||||
|
||||
@@ -22,20 +22,21 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
|
||||
private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2";
|
||||
private static final int EMOJI_LRU_SIZE = 50;
|
||||
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
|
||||
private static final int EMOJI_LRU_SIZE = 50;
|
||||
|
||||
private final SharedPreferences prefs;
|
||||
private final String preferenceName;
|
||||
private final LinkedHashSet<String> recentlyUsed;
|
||||
|
||||
public RecentEmojiPageModel(Context context) {
|
||||
this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
this.recentlyUsed = getPersistedCache();
|
||||
public RecentEmojiPageModel(Context context, @NonNull String preferenceName) {
|
||||
this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
this.preferenceName = preferenceName;
|
||||
this.recentlyUsed = getPersistedCache();
|
||||
}
|
||||
|
||||
private LinkedHashSet<String> getPersistedCache() {
|
||||
String serialized = prefs.getString(EMOJI_LRU_PREFERENCE, "[]");
|
||||
String serialized = prefs.getString(preferenceName, "[]");
|
||||
try {
|
||||
CollectionType collectionType = TypeFactory.defaultInstance()
|
||||
.constructCollectionType(LinkedHashSet.class, String.class);
|
||||
@@ -90,7 +91,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
try {
|
||||
String serialized = JsonUtils.toJson(latestRecentlyUsed);
|
||||
prefs.edit()
|
||||
.putString(EMOJI_LRU_PREFERENCE, serialized)
|
||||
.putString(preferenceName, serialized)
|
||||
.apply();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.CompoundButton;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.Switch;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class AudioOutputAdapter extends RecyclerView.Adapter<AudioOutputAdapter.ViewHolder> {
|
||||
|
||||
private final OnAudioOutputChangedListener onAudioOutputChangedListener;
|
||||
private final List<WebRtcAudioOutput> audioOutputs;
|
||||
|
||||
private WebRtcAudioOutput selected;
|
||||
|
||||
AudioOutputAdapter(@NonNull OnAudioOutputChangedListener onAudioOutputChangedListener,
|
||||
@NonNull List<WebRtcAudioOutput> audioOutputs) {
|
||||
this.audioOutputs = audioOutputs;
|
||||
this.onAudioOutputChangedListener = onAudioOutputChangedListener;
|
||||
}
|
||||
|
||||
public void setSelectedOutput(@NonNull WebRtcAudioOutput selected) {
|
||||
this.selected = selected;
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.audio_output_adapter_radio_item, parent, false);
|
||||
|
||||
return new ViewHolder(view, this::handlePositionSelected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
holder.bind(audioOutputs.get(position), selected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return audioOutputs.size();
|
||||
}
|
||||
|
||||
private void handlePositionSelected(int position) {
|
||||
WebRtcAudioOutput mode = audioOutputs.get(position);
|
||||
|
||||
if (mode != selected) {
|
||||
setSelectedOutput(mode);
|
||||
onAudioOutputChangedListener.audioOutputChanged(selected);
|
||||
}
|
||||
}
|
||||
|
||||
static class ViewHolder extends RecyclerView.ViewHolder implements CompoundButton.OnCheckedChangeListener {
|
||||
|
||||
private final TextView textView;
|
||||
private final RadioButton radioButton;
|
||||
private final Consumer<Integer> onPressed;
|
||||
|
||||
|
||||
public ViewHolder(@NonNull View itemView, @NonNull Consumer<Integer> onPressed) {
|
||||
super(itemView);
|
||||
|
||||
this.textView = itemView.findViewById(R.id.text);
|
||||
this.radioButton = itemView.findViewById(R.id.radio);
|
||||
this.onPressed = onPressed;
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
void bind(@NonNull WebRtcAudioOutput audioOutput, @Nullable WebRtcAudioOutput selected) {
|
||||
textView.setText(audioOutput.getLabelRes());
|
||||
textView.setCompoundDrawablesRelativeWithIntrinsicBounds(audioOutput.getIconRes(), 0, 0, 0);
|
||||
|
||||
radioButton.setOnCheckedChangeListener(null);
|
||||
radioButton.setChecked(audioOutput == selected);
|
||||
radioButton.setOnCheckedChangeListener(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
int adapterPosition = getAdapterPosition();
|
||||
if (adapterPosition != RecyclerView.NO_POSITION) {
|
||||
onPressed.accept(adapterPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
public interface OnAudioOutputChangedListener {
|
||||
void audioOutputChanged(WebRtcAudioOutput audioOutput);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.graphics.Point;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.VelocityTracker;
|
||||
import android.view.View;
|
||||
import android.view.ViewConfiguration;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.animation.Interpolator;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.view.GestureDetectorCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener {
|
||||
|
||||
private static final float DECELERATION_RATE = 0.99f;
|
||||
|
||||
private final ViewGroup parent;
|
||||
private final View child;
|
||||
private final int framePadding;
|
||||
private final int pipWidth;
|
||||
private final int pipHeight;
|
||||
|
||||
private int activePointerId = MotionEvent.INVALID_POINTER_ID;
|
||||
private float lastTouchX;
|
||||
private float lastTouchY;
|
||||
private boolean isDragging;
|
||||
private boolean isAnimating;
|
||||
private int extraPaddingTop;
|
||||
private int extraPaddingBottom;
|
||||
private double projectionX;
|
||||
private double projectionY;
|
||||
private VelocityTracker velocityTracker;
|
||||
private int maximumFlingVelocity;
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
public static PictureInPictureGestureHelper applyTo(@NonNull View child) {
|
||||
TouchInterceptingFrameLayout parent = (TouchInterceptingFrameLayout) child.getParent();
|
||||
PictureInPictureGestureHelper helper = new PictureInPictureGestureHelper(parent, child);
|
||||
GestureDetectorCompat gestureDetector = new GestureDetectorCompat(child.getContext(), helper);
|
||||
|
||||
parent.setOnInterceptTouchEventListener((event) -> {
|
||||
if (helper.velocityTracker == null) {
|
||||
helper.velocityTracker = VelocityTracker.obtain();
|
||||
}
|
||||
|
||||
helper.velocityTracker.addMovement(event);
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
parent.setOnTouchListener((v, event) -> {
|
||||
if (helper.velocityTracker != null) {
|
||||
helper.velocityTracker.recycle();
|
||||
helper.velocityTracker = null;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
child.setOnTouchListener((v, event) -> {
|
||||
boolean handled = gestureDetector.onTouchEvent(event);
|
||||
|
||||
if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
|
||||
if (!handled) {
|
||||
handled = helper.onGestureFinished(event);
|
||||
}
|
||||
|
||||
if (helper.velocityTracker != null) {
|
||||
helper.velocityTracker.recycle();
|
||||
helper.velocityTracker = null;
|
||||
}
|
||||
}
|
||||
|
||||
return handled;
|
||||
});
|
||||
|
||||
return helper;
|
||||
}
|
||||
|
||||
private PictureInPictureGestureHelper(@NonNull ViewGroup parent, @NonNull View child) {
|
||||
this.parent = parent;
|
||||
this.child = child;
|
||||
this.framePadding = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_frame_padding);
|
||||
this.pipWidth = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_width);
|
||||
this.pipHeight = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_height);
|
||||
this.maximumFlingVelocity = ViewConfiguration.get(child.getContext()).getScaledMaximumFlingVelocity();
|
||||
}
|
||||
|
||||
public void clearVerticalBoundaries() {
|
||||
setVerticalBoundaries(0, parent.getMeasuredHeight());
|
||||
}
|
||||
|
||||
public void setVerticalBoundaries(int topBoundary, int bottomBoundary) {
|
||||
extraPaddingTop = topBoundary;
|
||||
extraPaddingBottom = parent.getMeasuredHeight() - bottomBoundary;
|
||||
|
||||
if (isAnimating) {
|
||||
fling();
|
||||
} else if (!isDragging) {
|
||||
onFling(null, null, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean onGestureFinished(MotionEvent e) {
|
||||
final int pointerIndex = e.findPointerIndex(activePointerId);
|
||||
|
||||
if (e.getActionIndex() == pointerIndex) {
|
||||
onFling(e, e, 0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onDown(MotionEvent e) {
|
||||
activePointerId = e.getPointerId(0);
|
||||
lastTouchX = e.getX(activePointerId) + child.getX();
|
||||
lastTouchY = e.getY(activePointerId) + child.getY();
|
||||
isDragging = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
|
||||
int pointerIndex = e2.findPointerIndex(activePointerId);
|
||||
float x = e2.getX(pointerIndex) + child.getX();
|
||||
float y = e2.getY(pointerIndex) + child.getY();
|
||||
float dx = x - lastTouchX;
|
||||
float dy = y - lastTouchY;
|
||||
|
||||
child.setTranslationX(child.getTranslationX() + dx);
|
||||
child.setTranslationY(child.getTranslationY() + dy);
|
||||
|
||||
lastTouchX = x;
|
||||
lastTouchY = y;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
|
||||
if (velocityTracker != null) {
|
||||
velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity);
|
||||
|
||||
projectionX = child.getX() + project(velocityTracker.getXVelocity());
|
||||
projectionY = child.getY() + project(velocityTracker.getYVelocity());
|
||||
} else {
|
||||
projectionX = child.getX();
|
||||
projectionY = child.getY();
|
||||
}
|
||||
|
||||
fling();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void fling() {
|
||||
Point projection = new Point((int) projectionX, (int) projectionY);
|
||||
Point nearestCornerPosition = findNearestCornerPosition(projection);
|
||||
|
||||
isAnimating = true;
|
||||
isDragging = false;
|
||||
|
||||
child.animate()
|
||||
.translationX(getTranslationXForPoint(nearestCornerPosition))
|
||||
.translationY(getTranslationYForPoint(nearestCornerPosition))
|
||||
.setDuration(250)
|
||||
.setInterpolator(new ViscousFluidInterpolator())
|
||||
.setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
isAnimating = false;
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
private Point findNearestCornerPosition(Point projection) {
|
||||
Point maxPoint = null;
|
||||
double maxDistance = Double.MAX_VALUE;
|
||||
|
||||
for (Point point : Arrays.asList(calculateTopLeftCoordinates(),
|
||||
calculateTopRightCoordinates(parent),
|
||||
calculateBottomLeftCoordinates(parent),
|
||||
calculateBottomRightCoordinates(parent)))
|
||||
{
|
||||
double distance = distance(point, projection);
|
||||
|
||||
if (distance < maxDistance) {
|
||||
maxDistance = distance;
|
||||
maxPoint = point;
|
||||
}
|
||||
}
|
||||
|
||||
return maxPoint;
|
||||
}
|
||||
|
||||
private float getTranslationXForPoint(Point destination) {
|
||||
return destination.x - child.getLeft();
|
||||
}
|
||||
|
||||
private float getTranslationYForPoint(Point destination) {
|
||||
return destination.y - child.getTop();
|
||||
}
|
||||
|
||||
private Point calculateTopLeftCoordinates() {
|
||||
return new Point(framePadding,
|
||||
framePadding + extraPaddingTop);
|
||||
}
|
||||
|
||||
private Point calculateTopRightCoordinates(@NonNull ViewGroup parent) {
|
||||
return new Point(parent.getMeasuredWidth() - pipWidth - framePadding,
|
||||
framePadding + extraPaddingTop);
|
||||
}
|
||||
|
||||
private Point calculateBottomLeftCoordinates(@NonNull ViewGroup parent) {
|
||||
return new Point(framePadding,
|
||||
parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom);
|
||||
}
|
||||
|
||||
private Point calculateBottomRightCoordinates(@NonNull ViewGroup parent) {
|
||||
return new Point(parent.getMeasuredWidth() - pipWidth - framePadding,
|
||||
parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom);
|
||||
}
|
||||
|
||||
private static float project(float initialVelocity) {
|
||||
return (initialVelocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE);
|
||||
}
|
||||
|
||||
private static double distance(Point a, Point b) {
|
||||
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
|
||||
}
|
||||
|
||||
/** Borrowed from ScrollView */
|
||||
private static class ViscousFluidInterpolator implements Interpolator {
|
||||
/** Controls the viscous fluid effect (how much of it). */
|
||||
private static final float VISCOUS_FLUID_SCALE = 8.0f;
|
||||
|
||||
private static final float VISCOUS_FLUID_NORMALIZE;
|
||||
private static final float VISCOUS_FLUID_OFFSET;
|
||||
|
||||
static {
|
||||
|
||||
// must be set to 1.0 (used in viscousFluid())
|
||||
VISCOUS_FLUID_NORMALIZE = 1.0f / viscousFluid(1.0f);
|
||||
// account for very small floating-point error
|
||||
VISCOUS_FLUID_OFFSET = 1.0f - VISCOUS_FLUID_NORMALIZE * viscousFluid(1.0f);
|
||||
}
|
||||
|
||||
private static float viscousFluid(float x) {
|
||||
x *= VISCOUS_FLUID_SCALE;
|
||||
if (x < 1.0f) {
|
||||
x -= (1.0f - (float)Math.exp(-x));
|
||||
} else {
|
||||
float start = 0.36787944117f; // 1/e == exp(-1)
|
||||
x = 1.0f - (float)Math.exp(1.0f - x);
|
||||
x = start + x * (1.0f - start);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getInterpolation(float input) {
|
||||
final float interpolated = VISCOUS_FLUID_NORMALIZE * viscousFluid(input);
|
||||
if (interpolated > 0) {
|
||||
return interpolated + VISCOUS_FLUID_OFFSET;
|
||||
}
|
||||
return interpolated;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.graphics.SurfaceTexture;
|
||||
import android.view.TextureView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.webrtc.EglBase;
|
||||
import org.webrtc.EglRenderer;
|
||||
import org.webrtc.RendererCommon;
|
||||
import org.webrtc.ThreadUtils;
|
||||
import org.webrtc.VideoFrame;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
/**
|
||||
* This class is a modified copy of {@link org.webrtc.SurfaceViewRenderer} designed to work with a
|
||||
* {@link SurfaceTexture} to facilitate easier animation, rounding, elevation, etc.
|
||||
*/
|
||||
public class SurfaceTextureEglRenderer extends EglRenderer implements TextureView.SurfaceTextureListener {
|
||||
|
||||
private static final String TAG = Log.tag(SurfaceTextureEglRenderer.class);
|
||||
|
||||
private final Object layoutLock = new Object();
|
||||
|
||||
private RendererCommon.RendererEvents rendererEvents;
|
||||
private boolean isFirstFrameRendered;
|
||||
private boolean isRenderingPaused;
|
||||
private int rotatedFrameWidth;
|
||||
private int rotatedFrameHeight;
|
||||
private int frameRotation;
|
||||
|
||||
public SurfaceTextureEglRenderer(@NonNull String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
public void init(@Nullable EglBase.Context sharedContext, @Nullable RendererCommon.RendererEvents rendererEvents, @NonNull int[] configAttributes, @NonNull RendererCommon.GlDrawer drawer) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
this.rendererEvents = rendererEvents;
|
||||
synchronized (this.layoutLock) {
|
||||
this.isFirstFrameRendered = false;
|
||||
this.rotatedFrameWidth = 0;
|
||||
this.rotatedFrameHeight = 0;
|
||||
this.frameRotation = 0;
|
||||
}
|
||||
|
||||
super.init(sharedContext, configAttributes, drawer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(@Nullable EglBase.Context sharedContext, @NonNull int[] configAttributes, @NonNull RendererCommon.GlDrawer drawer) {
|
||||
this.init(sharedContext, null, configAttributes, drawer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFpsReduction(float fps) {
|
||||
synchronized(this.layoutLock) {
|
||||
this.isRenderingPaused = fps == 0.0F;
|
||||
}
|
||||
|
||||
super.setFpsReduction(fps);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableFpsReduction() {
|
||||
synchronized(this.layoutLock) {
|
||||
this.isRenderingPaused = false;
|
||||
}
|
||||
|
||||
super.disableFpsReduction();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pauseVideo() {
|
||||
synchronized(this.layoutLock) {
|
||||
this.isRenderingPaused = true;
|
||||
}
|
||||
|
||||
super.pauseVideo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFrame(@NonNull VideoFrame frame) {
|
||||
this.updateFrameDimensionsAndReportEvents(frame);
|
||||
super.onFrame(frame);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
createEglSurface(surface);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
Log.d(TAG, "onSurfaceTextureSizeChanged: size: " + width + "x" + height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
|
||||
CountDownLatch completionLatch = new CountDownLatch(1);
|
||||
|
||||
releaseEglSurface(completionLatch::countDown);
|
||||
ThreadUtils.awaitUninterruptibly(completionLatch);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceTextureUpdated(SurfaceTexture surface) {
|
||||
}
|
||||
|
||||
private void updateFrameDimensionsAndReportEvents(VideoFrame frame) {
|
||||
synchronized(this.layoutLock) {
|
||||
if (!this.isRenderingPaused) {
|
||||
if (!this.isFirstFrameRendered) {
|
||||
this.isFirstFrameRendered = true;
|
||||
Log.d(TAG, "Reporting first rendered frame.");
|
||||
if (this.rendererEvents != null) {
|
||||
this.rendererEvents.onFirstFrameRendered();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.rotatedFrameWidth != frame.getRotatedWidth() || this.rotatedFrameHeight != frame.getRotatedHeight() || this.frameRotation != frame.getRotation()) {
|
||||
Log.d(TAG, "Reporting frame resolution changed to " + frame.getBuffer().getWidth() + "x" + frame.getBuffer().getHeight() + " with rotation " + frame.getRotation());
|
||||
if (this.rendererEvents != null) {
|
||||
this.rendererEvents.onFrameResolutionChanged(frame.getBuffer().getWidth(), frame.getBuffer().getHeight(), frame.getRotation());
|
||||
}
|
||||
|
||||
this.rotatedFrameWidth = frame.getRotatedWidth();
|
||||
this.rotatedFrameHeight = frame.getRotatedHeight();
|
||||
this.frameRotation = frame.getRotation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.SurfaceTexture;
|
||||
import android.os.Looper;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.TextureView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.webrtc.EglBase;
|
||||
import org.webrtc.EglRenderer;
|
||||
import org.webrtc.GlRectDrawer;
|
||||
import org.webrtc.RendererCommon;
|
||||
import org.webrtc.ThreadUtils;
|
||||
import org.webrtc.VideoFrame;
|
||||
import org.webrtc.VideoSink;
|
||||
|
||||
/**
|
||||
* This class is a modified version of {@link org.webrtc.SurfaceViewRenderer} which is based on {@link TextureView}
|
||||
*/
|
||||
public class TextureViewRenderer extends TextureView implements TextureView.SurfaceTextureListener, VideoSink, RendererCommon.RendererEvents {
|
||||
|
||||
private static final String TAG = Log.tag(TextureViewRenderer.class);
|
||||
|
||||
private final SurfaceTextureEglRenderer eglRenderer;
|
||||
private final RendererCommon.VideoLayoutMeasure videoLayoutMeasure = new RendererCommon.VideoLayoutMeasure();
|
||||
|
||||
private RendererCommon.RendererEvents rendererEvents;
|
||||
private int rotatedFrameWidth;
|
||||
private int rotatedFrameHeight;
|
||||
private boolean enableFixedSize;
|
||||
private int surfaceWidth;
|
||||
private int surfaceHeight;
|
||||
|
||||
public TextureViewRenderer(@NonNull Context context) {
|
||||
super(context);
|
||||
this.eglRenderer = new SurfaceTextureEglRenderer(getResourceName());
|
||||
this.setSurfaceTextureListener(this);
|
||||
}
|
||||
|
||||
public TextureViewRenderer(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
this.eglRenderer = new SurfaceTextureEglRenderer(getResourceName());
|
||||
this.setSurfaceTextureListener(this);
|
||||
}
|
||||
|
||||
public void init(@NonNull EglBase.Context sharedContext, @NonNull RendererCommon.RendererEvents rendererEvents) {
|
||||
this.init(sharedContext, rendererEvents, EglBase.CONFIG_PLAIN, new GlRectDrawer());
|
||||
}
|
||||
|
||||
public void init(@NonNull EglBase.Context sharedContext, @NonNull RendererCommon.RendererEvents rendererEvents, @NonNull int[] configAttributes, @NonNull RendererCommon.GlDrawer drawer) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
|
||||
this.rendererEvents = rendererEvents;
|
||||
this.rotatedFrameWidth = 0;
|
||||
this.rotatedFrameHeight = 0;
|
||||
|
||||
this.eglRenderer.init(sharedContext, this, configAttributes, drawer);
|
||||
}
|
||||
|
||||
public void release() {
|
||||
eglRenderer.release();
|
||||
}
|
||||
|
||||
public void addFrameListener(@NonNull EglRenderer.FrameListener listener, float scale, @NonNull RendererCommon.GlDrawer drawerParam) {
|
||||
eglRenderer.addFrameListener(listener, scale, drawerParam);
|
||||
}
|
||||
|
||||
public void addFrameListener(@NonNull EglRenderer.FrameListener listener, float scale) {
|
||||
eglRenderer.addFrameListener(listener, scale);
|
||||
}
|
||||
|
||||
public void removeFrameListener(@NonNull EglRenderer.FrameListener listener) {
|
||||
eglRenderer.removeFrameListener(listener);
|
||||
}
|
||||
|
||||
public void setEnableHardwareScaler(boolean enabled) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
|
||||
enableFixedSize = enabled;
|
||||
|
||||
updateSurfaceSize();
|
||||
}
|
||||
|
||||
public void setMirror(boolean mirror) {
|
||||
eglRenderer.setMirror(mirror);
|
||||
}
|
||||
|
||||
public void setScalingType(@NonNull RendererCommon.ScalingType scalingType) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
|
||||
videoLayoutMeasure.setScalingType(scalingType);
|
||||
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
public void setScalingType(@NonNull RendererCommon.ScalingType scalingTypeMatchOrientation,
|
||||
@NonNull RendererCommon.ScalingType scalingTypeMismatchOrientation)
|
||||
{
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
|
||||
videoLayoutMeasure.setScalingType(scalingTypeMatchOrientation, scalingTypeMismatchOrientation);
|
||||
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
public void setFpsReduction(float fps) {
|
||||
eglRenderer.setFpsReduction(fps);
|
||||
}
|
||||
|
||||
public void disableFpsReduction() {
|
||||
eglRenderer.disableFpsReduction();
|
||||
}
|
||||
|
||||
public void pauseVideo() {
|
||||
eglRenderer.pauseVideo();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthSpec, int heightSpec) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
|
||||
Point size = videoLayoutMeasure.measure(widthSpec, heightSpec, this.rotatedFrameWidth, this.rotatedFrameHeight);
|
||||
|
||||
setMeasuredDimension(size.x, size.y);
|
||||
|
||||
Log.d(TAG, "onMeasure(). New size: " + size.x + "x" + size.y);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
|
||||
eglRenderer.setLayoutAspectRatio((float)(right - left) / (float)(bottom - top));
|
||||
|
||||
updateSurfaceSize();
|
||||
}
|
||||
|
||||
private void updateSurfaceSize() {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
|
||||
if (!isAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.enableFixedSize && this.rotatedFrameWidth != 0 && this.rotatedFrameHeight != 0 && this.getWidth() != 0 && this.getHeight() != 0) {
|
||||
|
||||
float layoutAspectRatio = (float)this.getWidth() / (float)this.getHeight();
|
||||
float frameAspectRatio = (float)this.rotatedFrameWidth / (float)this.rotatedFrameHeight;
|
||||
|
||||
int drawnFrameWidth;
|
||||
int drawnFrameHeight;
|
||||
|
||||
if (frameAspectRatio > layoutAspectRatio) {
|
||||
drawnFrameWidth = (int)((float)this.rotatedFrameHeight * layoutAspectRatio);
|
||||
drawnFrameHeight = this.rotatedFrameHeight;
|
||||
} else {
|
||||
drawnFrameWidth = this.rotatedFrameWidth;
|
||||
drawnFrameHeight = (int)((float)this.rotatedFrameWidth / layoutAspectRatio);
|
||||
}
|
||||
|
||||
int width = Math.min(this.getWidth(), drawnFrameWidth);
|
||||
int height = Math.min(this.getHeight(), drawnFrameHeight);
|
||||
|
||||
Log.d(TAG, "updateSurfaceSize. Layout size: " + this.getWidth() + "x" + this.getHeight() + ", frame size: " + this.rotatedFrameWidth + "x" + this.rotatedFrameHeight + ", requested surface size: " + width + "x" + height + ", old surface size: " + this.surfaceWidth + "x" + this.surfaceHeight);
|
||||
|
||||
if (width != this.surfaceWidth || height != this.surfaceHeight) {
|
||||
this.surfaceWidth = width;
|
||||
this.surfaceHeight = height;
|
||||
getSurfaceTexture().setDefaultBufferSize(width, height);
|
||||
}
|
||||
} else {
|
||||
this.surfaceWidth = this.surfaceHeight = 0;
|
||||
this.getSurfaceTexture().setDefaultBufferSize(getMeasuredWidth(), getMeasuredHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFirstFrameRendered() {
|
||||
if (this.rendererEvents != null) {
|
||||
this.rendererEvents.onFirstFrameRendered();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFrameResolutionChanged(int videoWidth, int videoHeight, int rotation) {
|
||||
if (this.rendererEvents != null) {
|
||||
this.rendererEvents.onFrameResolutionChanged(videoWidth, videoHeight, rotation);
|
||||
}
|
||||
|
||||
int rotatedWidth = rotation != 0 && rotation != 180 ? videoHeight : videoWidth;
|
||||
int rotatedHeight = rotation != 0 && rotation != 180 ? videoWidth : videoHeight;
|
||||
this.postOrRun(() -> {
|
||||
this.rotatedFrameWidth = rotatedWidth;
|
||||
this.rotatedFrameHeight = rotatedHeight;
|
||||
this.updateSurfaceSize();
|
||||
this.requestLayout();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFrame(VideoFrame videoFrame) {
|
||||
eglRenderer.onFrame(videoFrame);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
|
||||
ThreadUtils.checkIsOnMainThread();
|
||||
|
||||
surfaceWidth = 0;
|
||||
surfaceHeight = 0;
|
||||
|
||||
updateSurfaceSize();
|
||||
|
||||
eglRenderer.onSurfaceTextureAvailable(surface, width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
|
||||
eglRenderer.onSurfaceTextureSizeChanged(surface, width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
|
||||
return eglRenderer.onSurfaceTextureDestroyed(surface);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceTextureUpdated(SurfaceTexture surface) {
|
||||
}
|
||||
|
||||
private String getResourceName() {
|
||||
try {
|
||||
return this.getResources().getResourceEntryName(this.getId());
|
||||
} catch (Resources.NotFoundException var2) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public void clearImage() {
|
||||
this.eglRenderer.clearImage();
|
||||
}
|
||||
|
||||
private void postOrRun(Runnable r) {
|
||||
if (Thread.currentThread() == Looper.getMainLooper().getThread()) {
|
||||
r.run();
|
||||
} else {
|
||||
this.post(r);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public enum WebRtcAudioOutput {
|
||||
HANDSET(R.string.WebRtcAudioOutputToggle__phone_earpiece, R.drawable.ic_handset_solid_24),
|
||||
SPEAKER(R.string.WebRtcAudioOutputToggle__speaker, R.drawable.ic_speaker_solid_24),
|
||||
HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.ic_speaker_bt_solid_24);
|
||||
|
||||
private final @StringRes int labelRes;
|
||||
private final @DrawableRes int iconRes;
|
||||
|
||||
WebRtcAudioOutput(@StringRes int labelRes, @DrawableRes int iconRes) {
|
||||
this.labelRes = labelRes;
|
||||
this.iconRes = iconRes;
|
||||
}
|
||||
|
||||
public int getIconRes() {
|
||||
return iconRes;
|
||||
}
|
||||
|
||||
public int getLabelRes() {
|
||||
return labelRes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class WebRtcAudioOutputToggleButton extends AppCompatImageView {
|
||||
|
||||
private static final String STATE_OUTPUT_INDEX = "audio.output.toggle.state.output.index";
|
||||
private static final String STATE_HEADSET_ENABLED = "audio.output.toggle.state.headset.enabled";
|
||||
private static final String STATE_HANDSET_ENABLED = "audio.output.toggle.state.handset.enabled";
|
||||
private static final String STATE_PARENT = "audio.output.toggle.state.parent";
|
||||
|
||||
private static final int[] SPEAKER_OFF = { R.attr.state_speaker_off };
|
||||
private static final int[] SPEAKER_ON = { R.attr.state_speaker_on };
|
||||
private static final int[] OUTPUT_HANDSET = { R.attr.state_handset_selected };
|
||||
private static final int[] OUTPUT_SPEAKER = { R.attr.state_speaker_selected };
|
||||
private static final int[] OUTPUT_HEADSET = { R.attr.state_headset_selected };
|
||||
private static final int[][] OUTPUT_ENUM = { SPEAKER_OFF, SPEAKER_ON, OUTPUT_HANDSET, OUTPUT_SPEAKER, OUTPUT_HEADSET };
|
||||
private static final List<WebRtcAudioOutput> OUTPUT_MODES = Arrays.asList(WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HEADSET);
|
||||
|
||||
private boolean isHeadsetAvailable;
|
||||
private boolean isHandsetAvailable;
|
||||
private int outputIndex;
|
||||
private OnAudioOutputChangedListener audioOutputChangedListener;
|
||||
private DialogInterface picker;
|
||||
|
||||
public WebRtcAudioOutputToggleButton(@NonNull Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public WebRtcAudioOutputToggleButton(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public WebRtcAudioOutputToggleButton(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
super.setOnClickListener((v) -> {
|
||||
List<WebRtcAudioOutput> availableModes = buildOutputModeList(isHeadsetAvailable, isHandsetAvailable);
|
||||
|
||||
if (availableModes.size() > 2 || !isHandsetAvailable) showPicker(availableModes);
|
||||
else setAudioOutput(OUTPUT_MODES.get((outputIndex + 1) % OUTPUT_MODES.size()), true);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
hidePicker();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] onCreateDrawableState(int extraSpace) {
|
||||
final int[] extra = OUTPUT_ENUM[outputIndex];
|
||||
final int[] drawableState = super.onCreateDrawableState(extraSpace + extra.length);
|
||||
mergeDrawableStates(drawableState, extra);
|
||||
return drawableState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnClickListener(@Nullable OnClickListener l) {
|
||||
throw new UnsupportedOperationException("This View does not support custom click listeners.");
|
||||
}
|
||||
|
||||
public void setControlAvailability(boolean isHandsetAvailable, boolean isHeadsetAvailable) {
|
||||
this.isHandsetAvailable = isHandsetAvailable;
|
||||
this.isHeadsetAvailable = isHeadsetAvailable;
|
||||
}
|
||||
|
||||
public void setAudioOutput(@NonNull WebRtcAudioOutput audioOutput, boolean notifyListener) {
|
||||
int oldIndex = outputIndex;
|
||||
outputIndex = resolveAudioOutputIndex(OUTPUT_MODES.lastIndexOf(audioOutput));
|
||||
|
||||
if (oldIndex != outputIndex) {
|
||||
refreshDrawableState();
|
||||
|
||||
if (notifyListener) {
|
||||
notifyListener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setOnAudioOutputChangedListener(@Nullable OnAudioOutputChangedListener listener) {
|
||||
this.audioOutputChangedListener = listener;
|
||||
}
|
||||
|
||||
private void showPicker(@NonNull List<WebRtcAudioOutput> availableModes) {
|
||||
RecyclerView rv = new RecyclerView(getContext());
|
||||
AudioOutputAdapter adapter = new AudioOutputAdapter(audioOutput -> {
|
||||
setAudioOutput(audioOutput, true);
|
||||
hidePicker();
|
||||
},
|
||||
availableModes);
|
||||
|
||||
adapter.setSelectedOutput(OUTPUT_MODES.get(outputIndex));
|
||||
|
||||
rv.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false));
|
||||
rv.setAdapter(adapter);
|
||||
|
||||
picker = new AlertDialog.Builder(getContext())
|
||||
.setTitle(R.string.WebRtcAudioOutputToggle__audio_output)
|
||||
.setView(rv)
|
||||
.setCancelable(true)
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Parcelable onSaveInstanceState() {
|
||||
Parcelable parentState = super.onSaveInstanceState();
|
||||
Bundle bundle = new Bundle();
|
||||
|
||||
bundle.putParcelable(STATE_PARENT, parentState);
|
||||
bundle.putInt(STATE_OUTPUT_INDEX, outputIndex);
|
||||
bundle.putBoolean(STATE_HEADSET_ENABLED, isHeadsetAvailable);
|
||||
bundle.putBoolean(STATE_HANDSET_ENABLED, isHandsetAvailable);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(Parcelable state) {
|
||||
if (state instanceof Bundle) {
|
||||
Bundle savedState = (Bundle) state;
|
||||
|
||||
isHeadsetAvailable = savedState.getBoolean(STATE_HEADSET_ENABLED);
|
||||
isHandsetAvailable = savedState.getBoolean(STATE_HANDSET_ENABLED);
|
||||
|
||||
setAudioOutput(OUTPUT_MODES.get(
|
||||
resolveAudioOutputIndex(savedState.getInt(STATE_OUTPUT_INDEX))),
|
||||
false
|
||||
);
|
||||
|
||||
super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT));
|
||||
} else {
|
||||
super.onRestoreInstanceState(state);
|
||||
}
|
||||
}
|
||||
|
||||
private void hidePicker() {
|
||||
if (picker != null) {
|
||||
picker.dismiss();
|
||||
picker = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyListener() {
|
||||
if (audioOutputChangedListener == null) return;
|
||||
|
||||
audioOutputChangedListener.audioOutputChanged(OUTPUT_MODES.get(outputIndex));
|
||||
}
|
||||
|
||||
private static List<WebRtcAudioOutput> buildOutputModeList(boolean isHeadsetAvailable, boolean isHandsetAvailable) {
|
||||
List<WebRtcAudioOutput> modes = new ArrayList(3);
|
||||
|
||||
modes.add(WebRtcAudioOutput.SPEAKER);
|
||||
|
||||
if (isHeadsetAvailable) {
|
||||
modes.add(WebRtcAudioOutput.HEADSET);
|
||||
}
|
||||
|
||||
if (isHandsetAvailable) {
|
||||
modes.add(WebRtcAudioOutput.HANDSET);
|
||||
}
|
||||
|
||||
return modes;
|
||||
};
|
||||
|
||||
private int resolveAudioOutputIndex(int desiredAudioOutputIndex) {
|
||||
if (isIllegalAudioOutputIndex(desiredAudioOutputIndex)) {
|
||||
throw new IllegalArgumentException("Unsupported index: " + desiredAudioOutputIndex);
|
||||
}
|
||||
if (isUnsupportedAudioOutput(desiredAudioOutputIndex, isHeadsetAvailable, isHandsetAvailable)) {
|
||||
if (!isHandsetAvailable) {
|
||||
return OUTPUT_MODES.lastIndexOf(WebRtcAudioOutput.SPEAKER);
|
||||
} else {
|
||||
return OUTPUT_MODES.indexOf(WebRtcAudioOutput.HANDSET);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHeadsetAvailable) {
|
||||
return desiredAudioOutputIndex % 2;
|
||||
}
|
||||
|
||||
return desiredAudioOutputIndex;
|
||||
}
|
||||
|
||||
private static boolean isIllegalAudioOutputIndex(int desiredAudioOutputIndex) {
|
||||
return desiredAudioOutputIndex < 0 || desiredAudioOutputIndex > OUTPUT_MODES.size();
|
||||
}
|
||||
|
||||
private static boolean isUnsupportedAudioOutput(int desiredAudioOutputIndex, boolean isHeadsetAvailable, boolean isHandsetAvailable) {
|
||||
return (OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HEADSET && !isHeadsetAvailable) ||
|
||||
(OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HANDSET && !isHandsetAvailable);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Build;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.CompoundButton;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import com.tomergoldst.tooltips.ToolTip;
|
||||
import com.tomergoldst.tooltips.ToolTipsManager;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public class WebRtcCallControls extends LinearLayout {
|
||||
|
||||
private static final String TAG = WebRtcCallControls.class.getSimpleName();
|
||||
|
||||
private AccessibleToggleButton audioMuteButton;
|
||||
private AccessibleToggleButton videoMuteButton;
|
||||
private AccessibleToggleButton speakerButton;
|
||||
private AccessibleToggleButton bluetoothButton;
|
||||
private AccessibleToggleButton cameraFlipButton;
|
||||
private boolean cameraFlipAvailable;
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public WebRtcCallControls(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
initialize();
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
|
||||
public WebRtcCallControls(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public WebRtcCallControls(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public WebRtcCallControls(Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
inflater.inflate(R.layout.webrtc_call_controls, this, true);
|
||||
|
||||
this.speakerButton = ViewUtil.findById(this, R.id.speakerButton);
|
||||
this.bluetoothButton = ViewUtil.findById(this, R.id.bluetoothButton);
|
||||
this.audioMuteButton = ViewUtil.findById(this, R.id.muteButton);
|
||||
this.videoMuteButton = ViewUtil.findById(this, R.id.video_mute_button);
|
||||
this.cameraFlipButton = ViewUtil.findById(this, R.id.camera_flip_button);
|
||||
}
|
||||
|
||||
public void setAudioMuteButtonListener(final MuteButtonListener listener) {
|
||||
audioMuteButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
|
||||
listener.onToggle(b);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setVideoMuteButtonListener(final MuteButtonListener listener) {
|
||||
videoMuteButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
boolean videoMuted = !isChecked;
|
||||
listener.onToggle(videoMuted);
|
||||
cameraFlipButton.setVisibility(!videoMuted && cameraFlipAvailable ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setCameraFlipButtonListener(final CameraFlipButtonListener listener) {
|
||||
cameraFlipButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
listener.onToggle();
|
||||
cameraFlipButton.setEnabled(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setSpeakerButtonListener(final SpeakerButtonListener listener) {
|
||||
speakerButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
listener.onSpeakerChange(isChecked);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setBluetoothButtonListener(final BluetoothButtonListener listener) {
|
||||
bluetoothButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
listener.onBluetoothChange(isChecked);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void updateAudioState(boolean isBluetoothAvailable) {
|
||||
AudioManager audioManager = ServiceUtil.getAudioManager(getContext());
|
||||
|
||||
if (!isBluetoothAvailable) {
|
||||
bluetoothButton.setVisibility(View.GONE);
|
||||
} else {
|
||||
bluetoothButton.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
if (audioManager.isBluetoothScoOn()) {
|
||||
bluetoothButton.setChecked(true, false);
|
||||
speakerButton.setChecked(false, false);
|
||||
} else if (audioManager.isSpeakerphoneOn()) {
|
||||
speakerButton.setChecked(true, false);
|
||||
bluetoothButton.setChecked(false, false);
|
||||
} else {
|
||||
speakerButton.setChecked(false, false);
|
||||
bluetoothButton.setChecked(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isVideoEnabled() {
|
||||
return videoMuteButton.isChecked();
|
||||
}
|
||||
|
||||
public void setVideoEnabled(boolean enabled) {
|
||||
videoMuteButton.setChecked(enabled, false);
|
||||
}
|
||||
|
||||
public void setVideoAvailable(boolean available) {
|
||||
videoMuteButton.setVisibility(available ? VISIBLE : GONE);
|
||||
}
|
||||
|
||||
public void setCameraFlipButtonEnabled(boolean enabled) {
|
||||
cameraFlipButton.setChecked(enabled, false);
|
||||
}
|
||||
|
||||
public void setCameraFlipAvailable(boolean available) {
|
||||
cameraFlipAvailable = available;
|
||||
cameraFlipButton.setVisibility(cameraFlipAvailable && isVideoEnabled() ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
public void setCameraFlipClickable(boolean clickable) {
|
||||
setControlEnabled(cameraFlipButton, clickable);
|
||||
}
|
||||
|
||||
public void setMicrophoneEnabled(boolean enabled) {
|
||||
audioMuteButton.setChecked(!enabled, false);
|
||||
}
|
||||
|
||||
public void setControlsEnabled(boolean enabled) {
|
||||
setControlEnabled(speakerButton, enabled);
|
||||
setControlEnabled(bluetoothButton, enabled);
|
||||
setControlEnabled(videoMuteButton, enabled);
|
||||
setControlEnabled(cameraFlipButton, enabled);
|
||||
setControlEnabled(audioMuteButton, enabled);
|
||||
}
|
||||
|
||||
private void setControlEnabled(@NonNull View view, boolean enabled) {
|
||||
if (enabled) {
|
||||
view.setAlpha(1.0f);
|
||||
view.setEnabled(true);
|
||||
} else {
|
||||
view.setAlpha(0.3f);
|
||||
view.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void displayVideoTooltip(ViewGroup viewGroup) {
|
||||
if (videoMuteButton.getVisibility() == VISIBLE) {
|
||||
final ToolTipsManager toolTipsManager = new ToolTipsManager();
|
||||
|
||||
ToolTip toolTip = new ToolTip.Builder(getContext(), videoMuteButton, viewGroup,
|
||||
getContext().getString(R.string.WebRtcCallControls_tap_to_enable_your_video),
|
||||
ToolTip.POSITION_BELOW).build();
|
||||
toolTipsManager.show(toolTip);
|
||||
|
||||
videoMuteButton.postDelayed(() -> toolTipsManager.findAndDismiss(videoMuteButton), 4000);
|
||||
}
|
||||
}
|
||||
|
||||
public static interface MuteButtonListener {
|
||||
public void onToggle(boolean isMuted);
|
||||
}
|
||||
|
||||
public static interface CameraFlipButtonListener {
|
||||
public void onToggle();
|
||||
}
|
||||
|
||||
public static interface SpeakerButtonListener {
|
||||
public void onSpeakerChange(boolean isSpeaker);
|
||||
}
|
||||
|
||||
public static interface BluetoothButtonListener {
|
||||
public void onBluetoothChange(boolean isBluetooth);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.media.AudioManager;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
|
||||
class WebRtcCallRepository {
|
||||
|
||||
private final AudioManager audioManager;
|
||||
|
||||
WebRtcCallRepository() {
|
||||
this.audioManager = ServiceUtil.getAudioManager(ApplicationDependencies.getApplication());
|
||||
}
|
||||
|
||||
WebRtcAudioOutput getAudioOutput() {
|
||||
if (audioManager.isBluetoothScoOn()) {
|
||||
return WebRtcAudioOutput.HEADSET;
|
||||
} else if (audioManager.isSpeakerphoneOn()) {
|
||||
return WebRtcAudioOutput.SPEAKER;
|
||||
} else {
|
||||
return WebRtcAudioOutput.HANDSET;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.ViewCompat;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
import org.thoughtcrime.securesms.ringrtc.CameraState;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.VerifySpan;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.webrtc.RendererCommon;
|
||||
import org.webrtc.SurfaceViewRenderer;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
|
||||
/**
|
||||
* A UI widget that encapsulates the entire in-call screen
|
||||
* for both initiators and responders.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
public class WebRtcCallScreen extends FrameLayout implements RecipientForeverObserver {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = WebRtcCallScreen.class.getSimpleName();
|
||||
|
||||
private ImageView photo;
|
||||
private SurfaceViewRenderer localRenderer;
|
||||
private PercentFrameLayout localRenderLayout;
|
||||
private PercentFrameLayout remoteRenderLayout;
|
||||
private PercentFrameLayout localLargeRenderLayout;
|
||||
private TextView name;
|
||||
private TextView phoneNumber;
|
||||
private TextView label;
|
||||
private TextView elapsedTime;
|
||||
private View untrustedIdentityContainer;
|
||||
private TextView untrustedIdentityExplanation;
|
||||
private Button acceptIdentityButton;
|
||||
private Button cancelIdentityButton;
|
||||
private TextView status;
|
||||
private FloatingActionButton endCallButton;
|
||||
private WebRtcCallControls controls;
|
||||
private RelativeLayout expandedInfo;
|
||||
private ViewGroup callHeader;
|
||||
|
||||
private WebRtcAnswerDeclineButton incomingCallButton;
|
||||
|
||||
private LiveRecipient recipient;
|
||||
private boolean minimized;
|
||||
|
||||
public WebRtcCallScreen(Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public WebRtcCallScreen(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public WebRtcCallScreen(Context context, AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public void setActiveCall(@NonNull Recipient personInfo, @NonNull String message, @Nullable String sas, SurfaceViewRenderer localRenderer, SurfaceViewRenderer remoteRenderer) {
|
||||
setCard(personInfo, message);
|
||||
setConnected(localRenderer, remoteRenderer);
|
||||
incomingCallButton.stopRingingAnimation();
|
||||
incomingCallButton.setVisibility(View.GONE);
|
||||
endCallButton.show();
|
||||
}
|
||||
|
||||
public void setActiveCall(@NonNull Recipient personInfo, @NonNull String message, @NonNull SurfaceViewRenderer localRenderer) {
|
||||
setCard(personInfo, message);
|
||||
setRinging(localRenderer);
|
||||
incomingCallButton.stopRingingAnimation();
|
||||
incomingCallButton.setVisibility(View.GONE);
|
||||
endCallButton.show();
|
||||
}
|
||||
|
||||
public void setIncomingCall(Recipient personInfo) {
|
||||
setCard(personInfo, getContext().getString(R.string.CallScreen_Incoming_call));
|
||||
endCallButton.hide();
|
||||
incomingCallButton.setVisibility(View.VISIBLE);
|
||||
incomingCallButton.startRingingAnimation();
|
||||
}
|
||||
|
||||
public void setUntrustedIdentity(Recipient personInfo, IdentityKey untrustedIdentity) {
|
||||
String name = recipient.get().toShortString(getContext());
|
||||
String introduction = String.format(getContext().getString(R.string.WebRtcCallScreen_new_safety_numbers), name, name);
|
||||
SpannableString spannableString = new SpannableString(introduction + " " + getContext().getString(R.string.WebRtcCallScreen_you_may_wish_to_verify_this_contact));
|
||||
|
||||
spannableString.setSpan(new VerifySpan(getContext(), personInfo.getId(), untrustedIdentity),
|
||||
introduction.length()+1, spannableString.length(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
if (this.recipient != null) this.recipient.removeForeverObserver(this);
|
||||
this.recipient = personInfo.live();
|
||||
this.recipient.observeForever(this);
|
||||
|
||||
setPersonInfo(personInfo);
|
||||
|
||||
incomingCallButton.stopRingingAnimation();
|
||||
incomingCallButton.setVisibility(View.GONE);
|
||||
this.status.setText(R.string.WebRtcCallScreen_new_safety_number_title);
|
||||
this.untrustedIdentityContainer.setVisibility(View.VISIBLE);
|
||||
this.untrustedIdentityExplanation.setText(spannableString);
|
||||
this.untrustedIdentityExplanation.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
|
||||
this.endCallButton.hide();
|
||||
}
|
||||
|
||||
public void setIncomingCallActionListener(WebRtcAnswerDeclineButton.AnswerDeclineListener listener) {
|
||||
incomingCallButton.setAnswerDeclineListener(listener);
|
||||
}
|
||||
|
||||
public void setAudioMuteButtonListener(WebRtcCallControls.MuteButtonListener listener) {
|
||||
this.controls.setAudioMuteButtonListener(listener);
|
||||
}
|
||||
|
||||
public void setVideoMuteButtonListener(WebRtcCallControls.MuteButtonListener listener) {
|
||||
this.controls.setVideoMuteButtonListener(listener);
|
||||
}
|
||||
|
||||
public void setCameraFlipButtonListener(WebRtcCallControls.CameraFlipButtonListener listener) {
|
||||
this.controls.setCameraFlipButtonListener(listener);
|
||||
}
|
||||
|
||||
public void setSpeakerButtonListener(WebRtcCallControls.SpeakerButtonListener listener) {
|
||||
this.controls.setSpeakerButtonListener(listener);
|
||||
}
|
||||
|
||||
public void setBluetoothButtonListener(WebRtcCallControls.BluetoothButtonListener listener) {
|
||||
this.controls.setBluetoothButtonListener(listener);
|
||||
}
|
||||
|
||||
public void setHangupButtonListener(final HangupButtonListener listener) {
|
||||
endCallButton.setOnClickListener(v -> listener.onClick());
|
||||
}
|
||||
|
||||
public void setAcceptIdentityListener(OnClickListener listener) {
|
||||
this.acceptIdentityButton.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
public void setCancelIdentityButton(OnClickListener listener) {
|
||||
this.cancelIdentityButton.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
public void updateAudioState(boolean isBluetoothAvailable, boolean isMicrophoneEnabled) {
|
||||
this.controls.updateAudioState(isBluetoothAvailable);
|
||||
this.controls.setMicrophoneEnabled(isMicrophoneEnabled);
|
||||
}
|
||||
|
||||
public void setControlsEnabled(boolean enabled) {
|
||||
this.controls.setControlsEnabled(enabled);
|
||||
}
|
||||
|
||||
public void setLocalVideoState(@NonNull CameraState cameraState, @NonNull SurfaceViewRenderer localRenderer) {
|
||||
this.controls.setVideoAvailable(cameraState.getCameraCount() > 0);
|
||||
this.controls.setVideoEnabled(cameraState.isEnabled());
|
||||
this.controls.setCameraFlipAvailable(cameraState.getCameraCount() > 1);
|
||||
this.controls.setCameraFlipClickable(cameraState.getActiveDirection() != CameraState.Direction.PENDING);
|
||||
this.controls.setCameraFlipButtonEnabled(cameraState.getActiveDirection() == CameraState.Direction.BACK);
|
||||
|
||||
localRenderer.setMirror(cameraState.getActiveDirection() == CameraState.Direction.FRONT);
|
||||
localRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
|
||||
|
||||
this.localRenderer = localRenderer;
|
||||
|
||||
if (localRenderLayout.getChildCount() != 0) {
|
||||
displayLocalRendererInSmallLayout(!cameraState.isEnabled());
|
||||
} else {
|
||||
displayLocalRendererInLargeLayout(!cameraState.isEnabled());
|
||||
}
|
||||
|
||||
localRenderer.setVisibility(cameraState.isEnabled() ? VISIBLE : INVISIBLE);
|
||||
}
|
||||
|
||||
public void setRemoteVideoEnabled(boolean enabled) {
|
||||
if (enabled && this.remoteRenderLayout.isHidden()) {
|
||||
this.photo.setVisibility(View.INVISIBLE);
|
||||
setMinimized(true);
|
||||
|
||||
this.remoteRenderLayout.setHidden(false);
|
||||
this.remoteRenderLayout.requestLayout();
|
||||
|
||||
if (localRenderLayout.isHidden()) this.controls.displayVideoTooltip(callHeader);
|
||||
} else if (!enabled && !this.remoteRenderLayout.isHidden()){
|
||||
setMinimized(false);
|
||||
this.photo.setVisibility(View.VISIBLE);
|
||||
this.remoteRenderLayout.setHidden(true);
|
||||
this.remoteRenderLayout.requestLayout();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isVideoEnabled() {
|
||||
return controls.isVideoEnabled();
|
||||
}
|
||||
|
||||
private void displayLocalRendererInLargeLayout(boolean hide) {
|
||||
if (localLargeRenderLayout.getChildCount() == 0) {
|
||||
localRenderLayout.removeAllViews();
|
||||
|
||||
if (localRenderer != null) {
|
||||
localLargeRenderLayout.addView(localRenderer);
|
||||
}
|
||||
}
|
||||
|
||||
localRenderLayout.setHidden(true);
|
||||
localRenderLayout.requestLayout();
|
||||
|
||||
localLargeRenderLayout.setHidden(hide);
|
||||
localLargeRenderLayout.requestLayout();
|
||||
|
||||
if (hide) {
|
||||
photo.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
photo.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void displayLocalRendererInSmallLayout(boolean hide) {
|
||||
if (localRenderLayout.getChildCount() == 0) {
|
||||
localLargeRenderLayout.removeAllViews();
|
||||
|
||||
if (localRenderer != null) {
|
||||
localRenderLayout.addView(localRenderer);
|
||||
}
|
||||
}
|
||||
|
||||
localLargeRenderLayout.setHidden(true);
|
||||
localLargeRenderLayout.requestLayout();
|
||||
|
||||
localRenderLayout.setHidden(hide);
|
||||
localRenderLayout.requestLayout();
|
||||
|
||||
if (remoteRenderLayout.isHidden()) {
|
||||
photo.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
inflater.inflate(R.layout.webrtc_call_screen, this, true);
|
||||
|
||||
this.elapsedTime = findViewById(R.id.elapsedTime);
|
||||
this.photo = findViewById(R.id.photo);
|
||||
this.localRenderLayout = findViewById(R.id.local_render_layout);
|
||||
this.remoteRenderLayout = findViewById(R.id.remote_render_layout);
|
||||
this.localLargeRenderLayout = findViewById(R.id.local_large_render_layout);
|
||||
this.phoneNumber = findViewById(R.id.phoneNumber);
|
||||
this.name = findViewById(R.id.name);
|
||||
this.label = findViewById(R.id.label);
|
||||
this.status = findViewById(R.id.callStateLabel);
|
||||
this.controls = findViewById(R.id.inCallControls);
|
||||
this.endCallButton = findViewById(R.id.hangup_fab);
|
||||
this.incomingCallButton = findViewById(R.id.answer_decline_button);
|
||||
this.untrustedIdentityContainer = findViewById(R.id.untrusted_layout);
|
||||
this.untrustedIdentityExplanation = findViewById(R.id.untrusted_explanation);
|
||||
this.acceptIdentityButton = findViewById(R.id.accept_safety_numbers);
|
||||
this.cancelIdentityButton = findViewById(R.id.cancel_safety_numbers);
|
||||
this.expandedInfo = findViewById(R.id.expanded_info);
|
||||
this.callHeader = findViewById(R.id.call_info_1);
|
||||
|
||||
this.localRenderLayout.setHidden(true);
|
||||
this.remoteRenderLayout.setHidden(true);
|
||||
this.minimized = false;
|
||||
|
||||
this.remoteRenderLayout.setOnClickListener(v -> {
|
||||
if (!this.remoteRenderLayout.isHidden()) {
|
||||
setMinimized(!minimized);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setRinging(SurfaceViewRenderer localRenderer) {
|
||||
if (localLargeRenderLayout.getChildCount() == 0) {
|
||||
if (localRenderer.getParent() != null) {
|
||||
((ViewGroup)localRenderer.getParent()).removeView(localRenderer);
|
||||
}
|
||||
|
||||
localLargeRenderLayout.setPosition(0, 0, 100, 100);
|
||||
|
||||
localRenderer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
localRenderer.setMirror(true);
|
||||
localRenderer.setZOrderMediaOverlay(true);
|
||||
|
||||
localLargeRenderLayout.addView(localRenderer);
|
||||
|
||||
this.localRenderer = localRenderer;
|
||||
}
|
||||
}
|
||||
|
||||
private void setConnected(SurfaceViewRenderer localRenderer, SurfaceViewRenderer remoteRenderer) {
|
||||
if (localRenderLayout.getChildCount() == 0) {
|
||||
if (localRenderer.getParent() != null) {
|
||||
((ViewGroup)localRenderer.getParent()).removeView(localRenderer);
|
||||
}
|
||||
|
||||
if (remoteRenderer.getParent() != null) {
|
||||
((ViewGroup)remoteRenderer.getParent()).removeView(remoteRenderer);
|
||||
}
|
||||
|
||||
localRenderLayout.setPosition(7, 70, 25, 25);
|
||||
remoteRenderLayout.setPosition(0, 0, 100, 100);
|
||||
|
||||
localRenderer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
remoteRenderer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
localRenderer.setMirror(true);
|
||||
localRenderer.setZOrderMediaOverlay(true);
|
||||
|
||||
localRenderLayout.addView(localRenderer);
|
||||
remoteRenderLayout.addView(remoteRenderer);
|
||||
|
||||
this.localRenderer = localRenderer;
|
||||
}
|
||||
}
|
||||
|
||||
private void setPersonInfo(final @NonNull Recipient recipient) {
|
||||
GlideApp.with(getContext().getApplicationContext())
|
||||
.load(recipient.getContactPhoto())
|
||||
.fallback(recipient.getFallbackContactPhoto().asCallCard(getContext()))
|
||||
.error(recipient.getFallbackContactPhoto().asCallCard(getContext()))
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.into(this.photo);
|
||||
|
||||
if (FeatureFlags.profileDisplay()) {
|
||||
this.name.setText(recipient.getDisplayName(getContext()));
|
||||
|
||||
if (recipient.getE164().isPresent()) {
|
||||
this.phoneNumber.setText(recipient.requireE164());
|
||||
this.phoneNumber.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
this.phoneNumber.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
this.name.setText(recipient.getName(getContext()));
|
||||
|
||||
if (recipient.getName(getContext()) == null && !recipient.getProfileName().isEmpty()) {
|
||||
this.phoneNumber.setText(recipient.requireE164() + " (~" + recipient.getProfileName().toString() + ")");
|
||||
} else {
|
||||
this.phoneNumber.setText(recipient.requireE164());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setCard(Recipient recipient, String status) {
|
||||
if (this.recipient != null) this.recipient.removeForeverObserver(this);
|
||||
this.recipient = recipient.live();
|
||||
this.recipient.observeForever(this);
|
||||
|
||||
setPersonInfo(recipient);
|
||||
|
||||
this.status.setText(status);
|
||||
this.untrustedIdentityContainer.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void setMinimized(boolean minimized) {
|
||||
if (minimized) {
|
||||
ViewCompat.animate(callHeader).translationY(-1 * expandedInfo.getHeight());
|
||||
ViewCompat.animate(status).alpha(0);
|
||||
ViewCompat.animate(endCallButton).translationY(endCallButton.getHeight() + ViewUtil.dpToPx(getContext(), 40));
|
||||
ViewCompat.animate(endCallButton).alpha(0);
|
||||
|
||||
this.minimized = true;
|
||||
} else {
|
||||
ViewCompat.animate(callHeader).translationY(0);
|
||||
ViewCompat.animate(status).alpha(1);
|
||||
ViewCompat.animate(endCallButton).translationY(0);
|
||||
ViewCompat.animate(endCallButton).alpha(1).withEndAction(() -> {
|
||||
// Note: This is to work around an Android bug, see #6225
|
||||
endCallButton.requestLayout();
|
||||
});
|
||||
|
||||
this.minimized = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
setPersonInfo(recipient);
|
||||
}
|
||||
|
||||
public interface HangupButtonListener {
|
||||
void onClick();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewParent;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.transition.AutoTransition;
|
||||
import androidx.transition.Transition;
|
||||
import androidx.transition.TransitionManager;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.ringrtc.CameraState;
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.webrtc.RendererCommon;
|
||||
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class WebRtcCallView extends FrameLayout {
|
||||
|
||||
private static final long TRANSITION_DURATION_MILLIS = 250;
|
||||
private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8;
|
||||
private static final int LARGE_ONGOING_CALL_BUTTON_MARGIN_DP = 16;
|
||||
private static final FallbackPhotoProvider FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider();
|
||||
|
||||
public static final int FADE_OUT_DELAY = 5000;
|
||||
|
||||
private TextureViewRenderer localRenderer;
|
||||
private WebRtcAudioOutputToggleButton audioToggle;
|
||||
private AccessibleToggleButton videoToggle;
|
||||
private AccessibleToggleButton micToggle;
|
||||
private ViewGroup largeLocalRenderContainer;
|
||||
private ViewGroup localRenderPipFrame;
|
||||
private ViewGroup smallLocalRenderContainer;
|
||||
private ViewGroup remoteRenderContainer;
|
||||
private TextView recipientName;
|
||||
private TextView status;
|
||||
private ConstraintLayout parent;
|
||||
private AvatarImageView avatar;
|
||||
private ImageView avatarCard;
|
||||
private ControlsListener controlsListener;
|
||||
private RecipientId recipientId;
|
||||
private CameraState.Direction cameraDirection;
|
||||
private ImageView answer;
|
||||
private ImageView cameraDirectionToggle;
|
||||
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
|
||||
private ImageView hangup;
|
||||
private View answerWithAudio;
|
||||
private View answerWithAudioLabel;
|
||||
private View ongoingFooterGradient;
|
||||
|
||||
private final Set<View> incomingCallViews = new HashSet<>();
|
||||
private final Set<View> topViews = new HashSet<>();
|
||||
private final Set<View> visibleViewSet = new HashSet<>();
|
||||
private final Set<View> adjustableMarginsSet = new HashSet<>();
|
||||
|
||||
private WebRtcControls controls = WebRtcControls.NONE;
|
||||
private final Runnable fadeOutRunnable = () -> {
|
||||
if (isAttachedToWindow() && controls.isFadeOutEnabled()) fadeOutControls(); };
|
||||
|
||||
public WebRtcCallView(@NonNull Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public WebRtcCallView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
|
||||
LayoutInflater.from(context).inflate(R.layout.webrtc_call_view, this, true);
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
@Override
|
||||
protected void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
audioToggle = findViewById(R.id.call_screen_speaker_toggle);
|
||||
videoToggle = findViewById(R.id.call_screen_video_toggle);
|
||||
micToggle = findViewById(R.id.call_screen_audio_mic_toggle);
|
||||
localRenderPipFrame = findViewById(R.id.call_screen_pip);
|
||||
largeLocalRenderContainer = findViewById(R.id.call_screen_large_local_renderer_holder);
|
||||
smallLocalRenderContainer = findViewById(R.id.call_screen_small_local_renderer_holder);
|
||||
remoteRenderContainer = findViewById(R.id.call_screen_remote_renderer_holder);
|
||||
recipientName = findViewById(R.id.call_screen_recipient_name);
|
||||
status = findViewById(R.id.call_screen_status);
|
||||
parent = findViewById(R.id.call_screen);
|
||||
avatar = findViewById(R.id.call_screen_recipient_avatar);
|
||||
avatarCard = findViewById(R.id.call_screen_recipient_avatar_call_card);
|
||||
answer = findViewById(R.id.call_screen_answer_call);
|
||||
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
|
||||
hangup = findViewById(R.id.call_screen_end_call);
|
||||
answerWithAudio = findViewById(R.id.call_screen_answer_with_audio);
|
||||
answerWithAudioLabel = findViewById(R.id.call_screen_answer_with_audio_label);
|
||||
ongoingFooterGradient = findViewById(R.id.call_screen_ongoing_footer_gradient);
|
||||
|
||||
View topGradient = findViewById(R.id.call_screen_header_gradient);
|
||||
View downCaret = findViewById(R.id.call_screen_down_arrow);
|
||||
View decline = findViewById(R.id.call_screen_decline_call);
|
||||
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
|
||||
View declineLabel = findViewById(R.id.call_screen_decline_call_label);
|
||||
View incomingFooterGradient = findViewById(R.id.call_screen_incoming_footer_gradient);
|
||||
|
||||
topViews.add(status);
|
||||
topViews.add(topGradient);
|
||||
topViews.add(recipientName);
|
||||
|
||||
incomingCallViews.add(answer);
|
||||
incomingCallViews.add(answerLabel);
|
||||
incomingCallViews.add(decline);
|
||||
incomingCallViews.add(declineLabel);
|
||||
incomingCallViews.add(incomingFooterGradient);
|
||||
|
||||
adjustableMarginsSet.add(micToggle);
|
||||
adjustableMarginsSet.add(cameraDirectionToggle);
|
||||
adjustableMarginsSet.add(videoToggle);
|
||||
adjustableMarginsSet.add(audioToggle);
|
||||
|
||||
audioToggle.setOnAudioOutputChangedListener(outputMode -> {
|
||||
runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged(outputMode));
|
||||
});
|
||||
|
||||
videoToggle.setOnCheckedChangeListener((v, isOn) -> {
|
||||
runIfNonNull(controlsListener, listener -> listener.onVideoChanged(isOn));
|
||||
});
|
||||
|
||||
micToggle.setOnCheckedChangeListener((v, isOn) -> {
|
||||
runIfNonNull(controlsListener, listener -> listener.onMicChanged(isOn));
|
||||
});
|
||||
|
||||
cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged));
|
||||
|
||||
hangup.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onEndCallPressed));
|
||||
decline.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDenyCallPressed));
|
||||
|
||||
downCaret.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDownCaretPressed));
|
||||
|
||||
answer.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallPressed));
|
||||
answerWithAudio.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed));
|
||||
|
||||
setOnClickListener(v -> toggleControls());
|
||||
avatar.setOnClickListener(v -> toggleControls());
|
||||
|
||||
pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(localRenderPipFrame);
|
||||
|
||||
int statusBarHeight = ViewUtil.getStatusBarHeight(this);
|
||||
MarginLayoutParams params = (MarginLayoutParams) parent.getLayoutParams();
|
||||
|
||||
params.topMargin = statusBarHeight;
|
||||
parent.setLayoutParams(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
|
||||
if (controls.isFadeOutEnabled()) {
|
||||
scheduleFadeOut();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
cancelFadeOut();
|
||||
}
|
||||
|
||||
public void setControlsListener(@Nullable ControlsListener controlsListener) {
|
||||
this.controlsListener = controlsListener;
|
||||
}
|
||||
|
||||
public void setMicEnabled(boolean isMicEnabled) {
|
||||
micToggle.setChecked(isMicEnabled, false);
|
||||
}
|
||||
|
||||
public void setRemoteVideoEnabled(boolean isRemoteVideoEnabled) {
|
||||
if (isRemoteVideoEnabled) {
|
||||
remoteRenderContainer.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
remoteRenderContainer.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setLocalRenderer(@Nullable TextureViewRenderer surfaceViewRenderer) {
|
||||
if (localRenderer == surfaceViewRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
localRenderer = surfaceViewRenderer;
|
||||
|
||||
if (surfaceViewRenderer == null) {
|
||||
setRenderer(largeLocalRenderContainer, null);
|
||||
setRenderer(smallLocalRenderContainer, null);
|
||||
} else {
|
||||
localRenderer.setMirror(cameraDirection == CameraState.Direction.FRONT);
|
||||
localRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
|
||||
}
|
||||
}
|
||||
|
||||
public void setRemoteRenderer(@Nullable TextureViewRenderer remoteRenderer) {
|
||||
setRenderer(remoteRenderContainer, remoteRenderer);
|
||||
}
|
||||
|
||||
public void setLocalRenderState(WebRtcLocalRenderState localRenderState) {
|
||||
|
||||
videoToggle.setChecked(localRenderState != WebRtcLocalRenderState.GONE, false);
|
||||
|
||||
switch (localRenderState) {
|
||||
case GONE:
|
||||
localRenderPipFrame.setVisibility(View.GONE);
|
||||
largeLocalRenderContainer.setVisibility(View.GONE);
|
||||
setRenderer(largeLocalRenderContainer, null);
|
||||
setRenderer(smallLocalRenderContainer, null);
|
||||
break;
|
||||
case LARGE:
|
||||
localRenderPipFrame.setVisibility(View.GONE);
|
||||
largeLocalRenderContainer.setVisibility(View.VISIBLE);
|
||||
if (largeLocalRenderContainer.getChildCount() == 0) {
|
||||
setRenderer(largeLocalRenderContainer, localRenderer);
|
||||
}
|
||||
break;
|
||||
case SMALL:
|
||||
localRenderPipFrame.setVisibility(View.VISIBLE);
|
||||
largeLocalRenderContainer.setVisibility(View.GONE);
|
||||
|
||||
if (smallLocalRenderContainer.getChildCount() == 0) {
|
||||
setRenderer(smallLocalRenderContainer, localRenderer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setCameraDirection(@NonNull CameraState.Direction cameraDirection) {
|
||||
this.cameraDirection = cameraDirection;
|
||||
|
||||
if (localRenderer != null) {
|
||||
localRenderer.setMirror(cameraDirection == CameraState.Direction.FRONT);
|
||||
}
|
||||
}
|
||||
|
||||
public void setRecipient(@NonNull Recipient recipient) {
|
||||
if (recipient.getId() == recipientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
recipientId = recipient.getId();
|
||||
recipientName.setText(recipient.getDisplayName(getContext()));
|
||||
avatar.setFallbackPhotoProvider(FALLBACK_PHOTO_PROVIDER);
|
||||
avatar.setAvatar(GlideApp.with(this), recipient, false);
|
||||
AvatarUtil.loadBlurredIconIntoViewBackground(recipient, this);
|
||||
|
||||
setRecipientCallCard(recipient);
|
||||
}
|
||||
|
||||
public void showCallCard(boolean showCallCard) {
|
||||
avatarCard.setVisibility(showCallCard ? VISIBLE : GONE);
|
||||
avatar.setVisibility(showCallCard ? GONE : VISIBLE);
|
||||
}
|
||||
|
||||
public void setStatus(@NonNull String status) {
|
||||
this.status.setText(status);
|
||||
}
|
||||
|
||||
public void setStatusFromHangupType(@NonNull HangupMessage.Type hangupType) {
|
||||
switch (hangupType) {
|
||||
case NORMAL:
|
||||
status.setText(R.string.RedPhone_ending_call);
|
||||
break;
|
||||
case ACCEPTED:
|
||||
status.setText(R.string.WebRtcCallActivity__answered_on_a_linked_device);
|
||||
break;
|
||||
case DECLINED:
|
||||
status.setText(R.string.WebRtcCallActivity__declined_on_a_linked_device);
|
||||
break;
|
||||
case BUSY:
|
||||
status.setText(R.string.WebRtcCallActivity__busy_on_a_linked_device);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unknown hangup type: " + hangupType);
|
||||
}
|
||||
}
|
||||
|
||||
public void setWebRtcControls(WebRtcControls webRtcControls) {
|
||||
Set<View> lastVisibleSet = new HashSet<>(visibleViewSet);
|
||||
|
||||
visibleViewSet.clear();
|
||||
visibleViewSet.addAll(topViews);
|
||||
|
||||
if (webRtcControls.displayIncomingCallButtons()) {
|
||||
visibleViewSet.addAll(incomingCallViews);
|
||||
|
||||
status.setText(R.string.WebRtcCallView__signal_voice_call);
|
||||
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer));
|
||||
}
|
||||
|
||||
if (webRtcControls.displayAnswerWithAudio()) {
|
||||
visibleViewSet.add(answerWithAudio);
|
||||
visibleViewSet.add(answerWithAudioLabel);
|
||||
|
||||
status.setText(R.string.WebRtcCallView__signal_video_call);
|
||||
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video));
|
||||
}
|
||||
|
||||
if (webRtcControls.displayAudioToggle()) {
|
||||
visibleViewSet.add(audioToggle);
|
||||
|
||||
audioToggle.setControlAvailability(webRtcControls.enableHandsetInAudioToggle(),
|
||||
webRtcControls.enableHeadsetInAudioToggle());
|
||||
|
||||
audioToggle.setAudioOutput(webRtcControls.getAudioOutput(), false);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayCameraToggle()) {
|
||||
visibleViewSet.add(cameraDirectionToggle);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayEndCall()) {
|
||||
visibleViewSet.add(hangup);
|
||||
visibleViewSet.add(ongoingFooterGradient);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayMuteAudio()) {
|
||||
visibleViewSet.add(micToggle);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayVideoToggle()) {
|
||||
visibleViewSet.add(videoToggle);
|
||||
}
|
||||
|
||||
if (webRtcControls.displaySmallOngoingCallButtons()) {
|
||||
updateButtonStateForSmallButtons();
|
||||
} else if (webRtcControls.displayLargeOngoingCallButtons()) {
|
||||
updateButtonStateForLargeButtons();
|
||||
}
|
||||
|
||||
if (webRtcControls.isFadeOutEnabled()) {
|
||||
if (!controls.isFadeOutEnabled()) {
|
||||
scheduleFadeOut();
|
||||
}
|
||||
} else {
|
||||
cancelFadeOut();
|
||||
}
|
||||
|
||||
controls = webRtcControls;
|
||||
|
||||
if (!visibleViewSet.equals(lastVisibleSet) || !controls.isFadeOutEnabled()) {
|
||||
fadeInNewUiState(lastVisibleSet, webRtcControls.displaySmallOngoingCallButtons());
|
||||
post(() -> pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), videoToggle.getTop()));
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull View getVideoTooltipTarget() {
|
||||
return videoToggle;
|
||||
}
|
||||
|
||||
private void toggleControls() {
|
||||
if (controls.isFadeOutEnabled() && status.getVisibility() == VISIBLE) {
|
||||
fadeOutControls();
|
||||
} else {
|
||||
fadeInControls();
|
||||
}
|
||||
}
|
||||
|
||||
private void fadeOutControls() {
|
||||
fadeControls(ConstraintSet.GONE);
|
||||
controlsListener.onControlsFadeOut();
|
||||
pictureInPictureGestureHelper.clearVerticalBoundaries();
|
||||
}
|
||||
|
||||
private void fadeInControls() {
|
||||
fadeControls(ConstraintSet.VISIBLE);
|
||||
pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), videoToggle.getTop());
|
||||
|
||||
scheduleFadeOut();
|
||||
}
|
||||
|
||||
private void fadeControls(int visibility) {
|
||||
Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS);
|
||||
|
||||
TransitionManager.beginDelayedTransition(parent, transition);
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(parent);
|
||||
|
||||
for (View view : visibleViewSet) {
|
||||
constraintSet.setVisibility(view.getId(), visibility);
|
||||
}
|
||||
|
||||
constraintSet.applyTo(parent);
|
||||
}
|
||||
|
||||
private void fadeInNewUiState(@NonNull Set<View> previouslyVisibleViewSet, boolean useSmallMargins) {
|
||||
Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS);
|
||||
|
||||
TransitionManager.beginDelayedTransition(parent, transition);
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(parent);
|
||||
|
||||
for (View view : SetUtil.difference(previouslyVisibleViewSet, visibleViewSet)) {
|
||||
constraintSet.setVisibility(view.getId(), ConstraintSet.GONE);
|
||||
}
|
||||
|
||||
for (View view : visibleViewSet) {
|
||||
constraintSet.setVisibility(view.getId(), ConstraintSet.VISIBLE);
|
||||
|
||||
if (adjustableMarginsSet.contains(view)) {
|
||||
constraintSet.setMargin(view.getId(),
|
||||
ConstraintSet.END,
|
||||
ViewUtil.dpToPx(useSmallMargins ? SMALL_ONGOING_CALL_BUTTON_MARGIN_DP
|
||||
: LARGE_ONGOING_CALL_BUTTON_MARGIN_DP));
|
||||
}
|
||||
}
|
||||
|
||||
constraintSet.applyTo(parent);
|
||||
}
|
||||
|
||||
private void scheduleFadeOut() {
|
||||
cancelFadeOut();
|
||||
|
||||
if (getHandler() == null) return;
|
||||
getHandler().postDelayed(fadeOutRunnable, FADE_OUT_DELAY);
|
||||
}
|
||||
|
||||
private void cancelFadeOut() {
|
||||
if (getHandler() == null) return;
|
||||
getHandler().removeCallbacks(fadeOutRunnable);
|
||||
}
|
||||
|
||||
private static void runIfNonNull(@Nullable ControlsListener controlsListener, @NonNull Consumer<ControlsListener> controlsListenerConsumer) {
|
||||
if (controlsListener != null) {
|
||||
controlsListenerConsumer.accept(controlsListener);
|
||||
}
|
||||
}
|
||||
|
||||
private static void setRenderer(@NonNull ViewGroup container, @Nullable View renderer) {
|
||||
if (renderer == null) {
|
||||
container.removeAllViews();
|
||||
return;
|
||||
}
|
||||
|
||||
ViewParent parent = renderer.getParent();
|
||||
if (parent != null && parent != container) {
|
||||
((ViewGroup) parent).removeAllViews();
|
||||
}
|
||||
|
||||
if (parent == container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addView(renderer);
|
||||
}
|
||||
|
||||
private void setRecipientCallCard(@NonNull Recipient recipient) {
|
||||
ContactPhoto contactPhoto = recipient.getContactPhoto();
|
||||
FallbackContactPhoto fallbackPhoto = recipient.getFallbackContactPhoto(FALLBACK_PHOTO_PROVIDER);
|
||||
|
||||
GlideApp.with(this).load(contactPhoto)
|
||||
.fallback(fallbackPhoto.asCallCard(getContext()))
|
||||
.error(fallbackPhoto.asCallCard(getContext()))
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.into(this.avatarCard);
|
||||
|
||||
if (contactPhoto == null) this.avatarCard.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
|
||||
else this.avatarCard.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
|
||||
this.avatarCard.setBackgroundColor(recipient.getColor().toActionBarColor(getContext()));
|
||||
}
|
||||
|
||||
private void updateButtonStateForLargeButtons() {
|
||||
cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle);
|
||||
hangup.setImageResource(R.drawable.webrtc_call_screen_hangup);
|
||||
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle);
|
||||
videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle);
|
||||
audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle);
|
||||
}
|
||||
|
||||
private void updateButtonStateForSmallButtons() {
|
||||
cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle_small);
|
||||
hangup.setImageResource(R.drawable.webrtc_call_screen_hangup_small);
|
||||
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle_small);
|
||||
videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle_small);
|
||||
audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle_small);
|
||||
}
|
||||
|
||||
private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider {
|
||||
@Override
|
||||
public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() {
|
||||
return new ResourceContactPhoto(R.drawable.ic_profile_outline_120);
|
||||
}
|
||||
}
|
||||
|
||||
public interface ControlsListener {
|
||||
void onControlsFadeOut();
|
||||
void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput);
|
||||
void onVideoChanged(boolean isVideoEnabled);
|
||||
void onMicChanged(boolean isMicEnabled);
|
||||
void onCameraDirectionChanged();
|
||||
void onEndCallPressed();
|
||||
void onDenyCallPressed();
|
||||
void onAcceptCallWithVoiceOnlyPressed();
|
||||
void onAcceptCallPressed();
|
||||
void onDownCaretPressed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.ringrtc.CameraState;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
public class WebRtcCallViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<Boolean> remoteVideoEnabled = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
|
||||
private final MutableLiveData<WebRtcLocalRenderState> localRenderState = new MutableLiveData<>(WebRtcLocalRenderState.GONE);
|
||||
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<Boolean> localVideoEnabled = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<CameraState.Direction> cameraDirection = new MutableLiveData<>(CameraState.Direction.FRONT);
|
||||
private final LiveData<Boolean> shouldDisplayLocal = LiveDataUtil.combineLatest(isInPipMode, localVideoEnabled, (a, b) -> !a && b);
|
||||
private final LiveData<WebRtcLocalRenderState> realLocalRenderState = LiveDataUtil.combineLatest(shouldDisplayLocal, localRenderState, this::getRealLocalRenderState);
|
||||
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
|
||||
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls);
|
||||
private final SingleLiveEvent<Event> events = new SingleLiveEvent<Event>();
|
||||
private final MutableLiveData<Long> ellapsed = new MutableLiveData<>(-1L);
|
||||
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
|
||||
|
||||
private boolean canDisplayTooltipIfNeeded = true;
|
||||
private boolean hasEnabledLocalVideo = false;
|
||||
private long callConnectedTime = -1;
|
||||
private Handler ellapsedTimeHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean answerWithVideoAvailable = false;
|
||||
private Runnable ellapsedTimeRunnable = this::handleTick;
|
||||
|
||||
|
||||
private final WebRtcCallRepository repository = new WebRtcCallRepository();
|
||||
|
||||
public LiveData<Boolean> getRemoteVideoEnabled() {
|
||||
return Transformations.distinctUntilChanged(remoteVideoEnabled);
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getMicrophoneEnabled() {
|
||||
return Transformations.distinctUntilChanged(microphoneEnabled);
|
||||
}
|
||||
|
||||
public LiveData<CameraState.Direction> getCameraDirection() {
|
||||
return Transformations.distinctUntilChanged(cameraDirection);
|
||||
}
|
||||
|
||||
public LiveData<Boolean> displaySquareCallCard() {
|
||||
return isInPipMode;
|
||||
}
|
||||
|
||||
public LiveData<WebRtcLocalRenderState> getLocalRenderState() {
|
||||
return realLocalRenderState;
|
||||
}
|
||||
|
||||
public LiveData<WebRtcControls> getWebRtcControls() {
|
||||
return realWebRtcControls;
|
||||
}
|
||||
|
||||
public LiveRecipient getRecipient() {
|
||||
return liveRecipient.getValue();
|
||||
}
|
||||
|
||||
public void setRecipient(@NonNull Recipient recipient) {
|
||||
liveRecipient.setValue(recipient.live());
|
||||
}
|
||||
|
||||
public LiveData<Event> getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public LiveData<Long> getCallTime() {
|
||||
return Transformations.map(ellapsed, timeInCall -> callConnectedTime == -1 ? -1 : timeInCall);
|
||||
}
|
||||
|
||||
public boolean isAnswerWithVideoAvailable() {
|
||||
return answerWithVideoAvailable;
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void setIsInPipMode(boolean isInPipMode) {
|
||||
this.isInPipMode.setValue(isInPipMode);
|
||||
}
|
||||
|
||||
public void onDismissedVideoTooltip() {
|
||||
canDisplayTooltipIfNeeded = false;
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void updateFromWebRtcViewModel(@NonNull WebRtcViewModel webRtcViewModel) {
|
||||
remoteVideoEnabled.setValue(webRtcViewModel.isRemoteVideoEnabled());
|
||||
microphoneEnabled.setValue(webRtcViewModel.isMicrophoneEnabled());
|
||||
|
||||
if (isValidCameraDirectionForUi(webRtcViewModel.getLocalCameraState().getActiveDirection())) {
|
||||
cameraDirection.setValue(webRtcViewModel.getLocalCameraState().getActiveDirection());
|
||||
}
|
||||
|
||||
localVideoEnabled.setValue(webRtcViewModel.getLocalCameraState().isEnabled());
|
||||
updateLocalRenderState(webRtcViewModel.getState());
|
||||
updateWebRtcControls(webRtcViewModel.getState(),
|
||||
webRtcViewModel.getLocalCameraState().isEnabled(),
|
||||
webRtcViewModel.isRemoteVideoEnabled(),
|
||||
webRtcViewModel.isRemoteVideoOffer(),
|
||||
webRtcViewModel.getLocalCameraState().getCameraCount() > 1,
|
||||
webRtcViewModel.isBluetoothAvailable(),
|
||||
repository.getAudioOutput());
|
||||
|
||||
if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) {
|
||||
callConnectedTime = webRtcViewModel.getCallConnectedTime();
|
||||
startTimer();
|
||||
} else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED) {
|
||||
cancelTimer();
|
||||
callConnectedTime = -1;
|
||||
}
|
||||
|
||||
if (webRtcViewModel.getLocalCameraState().isEnabled()) {
|
||||
canDisplayTooltipIfNeeded = false;
|
||||
hasEnabledLocalVideo = true;
|
||||
events.setValue(Event.DISMISS_VIDEO_TOOLTIP);
|
||||
}
|
||||
|
||||
// If remote video is enabled and we a) haven't shown our video and b) have not dismissed the popup
|
||||
if (canDisplayTooltipIfNeeded && webRtcViewModel.isRemoteVideoEnabled() && !hasEnabledLocalVideo) {
|
||||
canDisplayTooltipIfNeeded = false;
|
||||
events.setValue(Event.SHOW_VIDEO_TOOLTIP);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isValidCameraDirectionForUi(CameraState.Direction direction) {
|
||||
return direction == CameraState.Direction.FRONT || direction == CameraState.Direction.BACK;
|
||||
}
|
||||
|
||||
private void updateLocalRenderState(WebRtcViewModel.State state) {
|
||||
if (state == WebRtcViewModel.State.CALL_CONNECTED) {
|
||||
localRenderState.setValue(WebRtcLocalRenderState.SMALL);
|
||||
} else {
|
||||
localRenderState.setValue(WebRtcLocalRenderState.LARGE);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateWebRtcControls(WebRtcViewModel.State state,
|
||||
boolean isLocalVideoEnabled,
|
||||
boolean isRemoteVideoEnabled,
|
||||
boolean isRemoteVideoOffer,
|
||||
boolean isMoreThanOneCameraAvailable,
|
||||
boolean isBluetoothAvailable,
|
||||
WebRtcAudioOutput audioOutput)
|
||||
{
|
||||
|
||||
final WebRtcControls.CallState callState;
|
||||
|
||||
switch (state) {
|
||||
case CALL_INCOMING:
|
||||
callState = WebRtcControls.CallState.INCOMING;
|
||||
answerWithVideoAvailable = isRemoteVideoOffer;
|
||||
break;
|
||||
default:
|
||||
callState = WebRtcControls.CallState.ONGOING;
|
||||
}
|
||||
|
||||
webRtcControls.setValue(new WebRtcControls(isLocalVideoEnabled,
|
||||
isRemoteVideoEnabled || isRemoteVideoOffer,
|
||||
isMoreThanOneCameraAvailable,
|
||||
isBluetoothAvailable,
|
||||
callState,
|
||||
audioOutput));
|
||||
}
|
||||
|
||||
private @NonNull WebRtcLocalRenderState getRealLocalRenderState(boolean shouldDisplayLocalVideo, @NonNull WebRtcLocalRenderState state) {
|
||||
if (shouldDisplayLocalVideo) return state;
|
||||
else return WebRtcLocalRenderState.GONE;
|
||||
}
|
||||
|
||||
private @NonNull WebRtcControls getRealWebRtcControls(boolean neverDisplayControls, @NonNull WebRtcControls controls) {
|
||||
if (neverDisplayControls) return WebRtcControls.NONE;
|
||||
else return controls;
|
||||
}
|
||||
|
||||
private void startTimer() {
|
||||
cancelTimer();
|
||||
|
||||
ellapsedTimeHandler.post(ellapsedTimeRunnable);
|
||||
}
|
||||
|
||||
private void handleTick() {
|
||||
if (callConnectedTime == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
long newValue = (System.currentTimeMillis() - callConnectedTime) / 1000;
|
||||
|
||||
ellapsed.postValue(newValue);
|
||||
|
||||
ellapsedTimeHandler.postDelayed(ellapsedTimeRunnable, 1000);
|
||||
}
|
||||
|
||||
private void cancelTimer() {
|
||||
ellapsedTimeHandler.removeCallbacks(ellapsedTimeRunnable);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
super.onCleared();
|
||||
cancelTimer();
|
||||
}
|
||||
|
||||
public enum Event {
|
||||
SHOW_VIDEO_TOOLTIP,
|
||||
DISMISS_VIDEO_TOOLTIP
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class WebRtcControls {
|
||||
|
||||
public static final WebRtcControls NONE = new WebRtcControls();
|
||||
|
||||
private final boolean isRemoteVideoEnabled;
|
||||
private final boolean isLocalVideoEnabled;
|
||||
private final boolean isMoreThanOneCameraAvailable;
|
||||
private final boolean isBluetoothAvailable;
|
||||
private final CallState callState;
|
||||
private final WebRtcAudioOutput audioOutput;
|
||||
|
||||
private WebRtcControls() {
|
||||
this(false, false, false, false, CallState.NONE, WebRtcAudioOutput.HANDSET);
|
||||
}
|
||||
|
||||
WebRtcControls(boolean isLocalVideoEnabled,
|
||||
boolean isRemoteVideoEnabled,
|
||||
boolean isMoreThanOneCameraAvailable,
|
||||
boolean isBluetoothAvailable,
|
||||
@NonNull CallState callState,
|
||||
@NonNull WebRtcAudioOutput audioOutput)
|
||||
{
|
||||
this.isLocalVideoEnabled = isLocalVideoEnabled;
|
||||
this.isRemoteVideoEnabled = isRemoteVideoEnabled;
|
||||
this.isBluetoothAvailable = isBluetoothAvailable;
|
||||
this.isMoreThanOneCameraAvailable = isMoreThanOneCameraAvailable;
|
||||
this.callState = callState;
|
||||
this.audioOutput = audioOutput;
|
||||
}
|
||||
|
||||
boolean displayEndCall() {
|
||||
return isOngoing();
|
||||
}
|
||||
|
||||
boolean displayMuteAudio() {
|
||||
return isOngoing();
|
||||
}
|
||||
|
||||
boolean displayVideoToggle() {
|
||||
return isOngoing();
|
||||
}
|
||||
|
||||
boolean displayAudioToggle() {
|
||||
return isOngoing() && (!isLocalVideoEnabled || isBluetoothAvailable);
|
||||
}
|
||||
|
||||
boolean displayCameraToggle() {
|
||||
return isOngoing() && isLocalVideoEnabled && isMoreThanOneCameraAvailable;
|
||||
}
|
||||
|
||||
boolean displayAnswerWithAudio() {
|
||||
return isIncoming() && isRemoteVideoEnabled;
|
||||
}
|
||||
|
||||
boolean displayIncomingCallButtons() {
|
||||
return isIncoming();
|
||||
}
|
||||
|
||||
boolean enableHandsetInAudioToggle() {
|
||||
return !isLocalVideoEnabled;
|
||||
}
|
||||
|
||||
boolean enableHeadsetInAudioToggle() {
|
||||
return isBluetoothAvailable;
|
||||
}
|
||||
|
||||
boolean isFadeOutEnabled() {
|
||||
return isOngoing() && isRemoteVideoEnabled;
|
||||
}
|
||||
|
||||
boolean displaySmallOngoingCallButtons() {
|
||||
return isOngoing() && displayAudioToggle() && displayCameraToggle();
|
||||
}
|
||||
|
||||
boolean displayLargeOngoingCallButtons() {
|
||||
return isOngoing() && !(displayAudioToggle() && displayCameraToggle());
|
||||
}
|
||||
|
||||
WebRtcAudioOutput getAudioOutput() {
|
||||
return audioOutput;
|
||||
}
|
||||
|
||||
private boolean isOngoing() {
|
||||
return callState == CallState.ONGOING;
|
||||
}
|
||||
|
||||
private boolean isIncoming() {
|
||||
return callState == CallState.INCOMING;
|
||||
}
|
||||
|
||||
public enum CallState {
|
||||
NONE,
|
||||
INCOMING,
|
||||
ONGOING
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
public enum WebRtcLocalRenderState {
|
||||
GONE,
|
||||
SMALL,
|
||||
LARGE
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.ColorFilter;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
import com.google.android.material.chip.Chip;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
public final class ContactChip extends Chip {
|
||||
|
||||
@Nullable private SelectedContact contact;
|
||||
|
||||
public ContactChip(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public ContactChip(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public ContactChip(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public void setContact(@NonNull SelectedContact contact) {
|
||||
this.contact = contact;
|
||||
}
|
||||
|
||||
public @Nullable SelectedContact getContact() {
|
||||
return contact;
|
||||
}
|
||||
|
||||
public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, @Nullable Runnable onAvatarSet) {
|
||||
if (recipient != null) {
|
||||
requestManager.clear(this);
|
||||
|
||||
Drawable fallbackContactPhotoDrawable = new HalfScaleDrawable(recipient.getFallbackContactPhotoDrawable(getContext(), false));
|
||||
ContactPhoto contactPhoto = recipient.getContactPhoto();
|
||||
|
||||
if (contactPhoto == null) {
|
||||
setChipIcon(fallbackContactPhotoDrawable);
|
||||
if (onAvatarSet != null) {
|
||||
onAvatarSet.run();
|
||||
}
|
||||
} else {
|
||||
requestManager.load(contactPhoto)
|
||||
.placeholder(fallbackContactPhotoDrawable)
|
||||
.fallback(fallbackContactPhotoDrawable)
|
||||
.error(fallbackContactPhotoDrawable)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.circleCrop()
|
||||
.into(new CustomTarget<Drawable>() {
|
||||
@Override
|
||||
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
|
||||
setChipIcon(resource);
|
||||
if (onAvatarSet != null) {
|
||||
onAvatarSet.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadCleared(@Nullable Drawable placeholder) {
|
||||
setChipIcon(placeholder);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class HalfScaleDrawable extends Drawable {
|
||||
|
||||
private final Drawable fallbackContactPhotoDrawable;
|
||||
|
||||
HalfScaleDrawable(Drawable fallbackContactPhotoDrawable) {
|
||||
this.fallbackContactPhotoDrawable = fallbackContactPhotoDrawable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBounds(int left, int top, int right, int bottom) {
|
||||
super.setBounds(left, top, right, bottom);
|
||||
fallbackContactPhotoDrawable.setBounds(left, top, 2 * right - left, 2 * bottom - top);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBounds(@NonNull Rect bounds) {
|
||||
super.setBounds(bounds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas) {
|
||||
canvas.save();
|
||||
canvas.scale(0.5f, 0.5f);
|
||||
fallbackContactPhotoDrawable.draw(canvas);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAlpha(int alpha) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColorFilter(@Nullable ColorFilter colorFilter) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOpacity() {
|
||||
return PixelFormat.OPAQUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,11 +85,15 @@ public class ContactRepository {
|
||||
|
||||
@WorkerThread
|
||||
public Cursor querySignalContacts(@NonNull String query) {
|
||||
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts()
|
||||
: recipientDatabase.querySignalContacts(query);
|
||||
return querySignalContacts(query, true);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public Cursor querySignalContacts(@NonNull String query, boolean includeSelf) {
|
||||
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts(includeSelf)
|
||||
: recipientDatabase.querySignalContacts(query, includeSelf);
|
||||
|
||||
if (noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) {
|
||||
if (includeSelf && noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) {
|
||||
Recipient self = Recipient.self();
|
||||
boolean nameMatch = self.getDisplayName(context).toLowerCase().contains(query.toLowerCase());
|
||||
boolean numberMatch = self.getE164().isPresent() && self.requireE164().contains(query);
|
||||
|
||||
@@ -43,7 +43,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration.StickyHeaderAdapter;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -64,13 +65,35 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
private final static int STYLE_ATTRIBUTES[] = new int[]{R.attr.contact_selection_push_user,
|
||||
R.attr.contact_selection_lay_user};
|
||||
|
||||
public static final int PAYLOAD_SELECTION_CHANGE = 1;
|
||||
|
||||
private final boolean multiSelect;
|
||||
private final LayoutInflater li;
|
||||
private final LayoutInflater layoutInflater;
|
||||
private final TypedArray drawables;
|
||||
private final ItemClickListener clickListener;
|
||||
private final GlideRequests glideRequests;
|
||||
private final Set<RecipientId> currentContacts;
|
||||
|
||||
private final Set<SelectedContact> selectedContacts = new HashSet<>();
|
||||
private final SelectedContactSet selectedContacts = new SelectedContactSet();
|
||||
|
||||
public void clearSelectedContacts() {
|
||||
selectedContacts.clear();
|
||||
}
|
||||
|
||||
public boolean isSelectedContact(@NonNull SelectedContact contact) {
|
||||
return selectedContacts.contains(contact);
|
||||
}
|
||||
|
||||
public void addSelectedContact(@NonNull SelectedContact contact) {
|
||||
if (!selectedContacts.add(contact)) {
|
||||
Log.i(TAG, "Contact was already selected, possibly by another identifier");
|
||||
}
|
||||
}
|
||||
|
||||
public void removeFromSelectedContacts(@NonNull SelectedContact selectedContact) {
|
||||
int removed = selectedContacts.remove(selectedContact);
|
||||
Log.i(TAG, String.format(Locale.US, "Removed %d selected contacts that matched", removed));
|
||||
}
|
||||
|
||||
public abstract static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
@@ -78,9 +101,10 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean multiSelect);
|
||||
public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean checkboxVisible);
|
||||
public abstract void unbind(@NonNull GlideRequests glideRequests);
|
||||
public abstract void setChecked(boolean checked);
|
||||
public abstract void setEnabled(boolean enabled);
|
||||
}
|
||||
|
||||
public static class ContactViewHolder extends ViewHolder {
|
||||
@@ -97,8 +121,8 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
return (ContactSelectionListItem) itemView;
|
||||
}
|
||||
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean multiSelect) {
|
||||
getView().set(glideRequests, recipientId, type, name, number, label, color, multiSelect);
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean checkBoxVisible) {
|
||||
getView().set(glideRequests, recipientId, type, name, number, label, color, checkBoxVisible);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -110,6 +134,11 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
public void setChecked(boolean checked) {
|
||||
getView().setChecked(checked);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {
|
||||
getView().setEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
public static class DividerViewHolder extends ViewHolder {
|
||||
@@ -122,7 +151,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean multiSelect) {
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean checkboxVisible) {
|
||||
this.label.setText(name);
|
||||
}
|
||||
|
||||
@@ -131,6 +160,9 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
|
||||
@Override
|
||||
public void setChecked(boolean checked) {}
|
||||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {}
|
||||
}
|
||||
|
||||
static class HeaderViewHolder extends RecyclerView.ViewHolder {
|
||||
@@ -143,19 +175,22 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@Nullable Cursor cursor,
|
||||
@Nullable ItemClickListener clickListener,
|
||||
boolean multiSelect)
|
||||
boolean multiSelect,
|
||||
@NonNull Set<RecipientId> currentContacts)
|
||||
{
|
||||
super(context, cursor);
|
||||
this.li = LayoutInflater.from(context);
|
||||
this.glideRequests = glideRequests;
|
||||
this.drawables = context.obtainStyledAttributes(STYLE_ATTRIBUTES);
|
||||
this.multiSelect = multiSelect;
|
||||
this.clickListener = clickListener;
|
||||
this.layoutInflater = LayoutInflater.from(context);
|
||||
this.glideRequests = glideRequests;
|
||||
this.drawables = context.obtainStyledAttributes(STYLE_ATTRIBUTES);
|
||||
this.multiSelect = multiSelect;
|
||||
this.clickListener = clickListener;
|
||||
this.currentContacts = currentContacts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getHeaderId(int i) {
|
||||
if (!isActiveCursor()) return -1;
|
||||
else if (i == -1) return -1;
|
||||
|
||||
int contactType = getContactType(i);
|
||||
|
||||
@@ -166,9 +201,9 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
@Override
|
||||
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
|
||||
if (viewType == VIEW_TYPE_CONTACT) {
|
||||
return new ContactViewHolder(li.inflate(R.layout.contact_selection_list_item, parent, false), clickListener);
|
||||
return new ContactViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_item, parent, false), clickListener);
|
||||
} else {
|
||||
return new DividerViewHolder(li.inflate(R.layout.contact_selection_list_divider, parent, false));
|
||||
return new DividerViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_divider, parent, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,10 +222,39 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
int color = (contactType == ContactRepository.PUSH_TYPE) ? drawables.getColor(0, 0xa0000000) :
|
||||
drawables.getColor(1, 0xff000000);
|
||||
|
||||
viewHolder.unbind(glideRequests);
|
||||
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, color, multiSelect);
|
||||
boolean currentContact = currentContacts.contains(id);
|
||||
|
||||
if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
|
||||
viewHolder.unbind(glideRequests);
|
||||
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, color, multiSelect || currentContact);
|
||||
viewHolder.setEnabled(true);
|
||||
|
||||
if (currentContact) {
|
||||
viewHolder.setChecked(true);
|
||||
viewHolder.setEnabled(false);
|
||||
} else if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
|
||||
} else {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor, @NonNull List<Object> payloads) {
|
||||
if (!arePayloadsValid(payloads)) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
String rawId = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN));
|
||||
RecipientId id = rawId != null ? RecipientId.from(rawId) : null;
|
||||
int numberType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_TYPE_COLUMN));
|
||||
String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_COLUMN));
|
||||
|
||||
viewHolder.setEnabled(true);
|
||||
|
||||
if (currentContacts.contains(id)) {
|
||||
viewHolder.setChecked(true);
|
||||
viewHolder.setEnabled(false);
|
||||
} else if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
|
||||
} else {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
|
||||
@@ -206,7 +270,6 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position) {
|
||||
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.contact_selection_recyclerview_header, parent, false));
|
||||
@@ -217,6 +280,11 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
((TextView)viewHolder.itemView).setText(getSpannedHeaderString(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean arePayloadsValid(@NonNull List<Object> payloads) {
|
||||
return payloads.size() == 1 && payloads.get(0).equals(PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemViewRecycled(ViewHolder holder) {
|
||||
holder.unbind(glideRequests);
|
||||
@@ -227,8 +295,12 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
return getHeaderString(position);
|
||||
}
|
||||
|
||||
public Set<SelectedContact> getSelectedContacts() {
|
||||
return selectedContacts;
|
||||
public List<SelectedContact> getSelectedContacts() {
|
||||
return selectedContacts.getContacts();
|
||||
}
|
||||
|
||||
public int getSelectedContactsCount() {
|
||||
return selectedContacts.size();
|
||||
}
|
||||
|
||||
private CharSequence getSpannedHeaderString(int position) {
|
||||
|
||||
@@ -35,6 +35,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
private CheckBox checkBox;
|
||||
|
||||
private String number;
|
||||
private String chipName;
|
||||
private int contactType;
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
@@ -66,7 +67,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
String number,
|
||||
String label,
|
||||
int color,
|
||||
boolean multiSelect)
|
||||
boolean checkboxVisible)
|
||||
{
|
||||
this.glideRequests = glideRequests;
|
||||
this.number = number;
|
||||
@@ -89,14 +90,19 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
|
||||
setText(recipientSnapshot, type, name, number, label);
|
||||
|
||||
if (multiSelect) this.checkBox.setVisibility(View.VISIBLE);
|
||||
else this.checkBox.setVisibility(View.GONE);
|
||||
this.checkBox.setVisibility(checkboxVisible ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
public void setChecked(boolean selected) {
|
||||
this.checkBox.setChecked(selected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {
|
||||
super.setEnabled(enabled);
|
||||
this.checkBox.setEnabled(enabled);
|
||||
}
|
||||
|
||||
public void unbind(GlideRequests glideRequests) {
|
||||
if (recipient != null) {
|
||||
recipient.removeForeverObserver(this);
|
||||
@@ -106,10 +112,14 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private void setText(@Nullable Recipient recipient, int type, String name, String number, String label) {
|
||||
if (number == null || number.isEmpty() || GroupId.isEncodedGroup(number)) {
|
||||
if (number == null || number.isEmpty()) {
|
||||
this.nameView.setEnabled(false);
|
||||
this.numberView.setText("");
|
||||
this.labelView.setVisibility(View.GONE);
|
||||
} else if (recipient != null && recipient.isGroup()) {
|
||||
this.nameView.setEnabled(false);
|
||||
this.numberView.setText(getGroupMemberCount(recipient));
|
||||
this.labelView.setVisibility(View.GONE);
|
||||
} else if (type == ContactRepository.PUSH_TYPE) {
|
||||
this.numberView.setText(number);
|
||||
this.nameView.setEnabled(true);
|
||||
@@ -128,8 +138,10 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
|
||||
if (recipient != null) {
|
||||
this.nameView.setText(recipient);
|
||||
chipName = recipient.getShortDisplayName(getContext());
|
||||
} else {
|
||||
this.nameView.setText(name);
|
||||
chipName = name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +149,22 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
return number;
|
||||
}
|
||||
|
||||
public String getChipName() {
|
||||
return chipName;
|
||||
}
|
||||
|
||||
private String getGroupMemberCount(@NonNull Recipient recipient) {
|
||||
if (!recipient.isGroup()) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
int memberCount = recipient.getParticipants().size();
|
||||
return getContext().getResources().getQuantityString(R.plurals.contact_selection_list_item__number_of_members, memberCount, memberCount);
|
||||
}
|
||||
|
||||
public @Nullable LiveRecipient getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
public boolean isUsernameType() {
|
||||
return contactType == ContactRepository.NEW_USERNAME_TYPE;
|
||||
}
|
||||
@@ -149,5 +177,8 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient, false);
|
||||
nameView.setText(recipient);
|
||||
if (recipient.isGroup()) {
|
||||
numberView.setText(getGroupMemberCount(recipient));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ public class ContactsCursorLoader extends CursorLoader {
|
||||
public static final int FLAG_SMS = 1 << 1;
|
||||
public static final int FLAG_ACTIVE_GROUPS = 1 << 2;
|
||||
public static final int FLAG_INACTIVE_GROUPS = 1 << 3;
|
||||
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS;
|
||||
public static final int FLAG_SELF = 1 << 4;
|
||||
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
|
||||
}
|
||||
|
||||
private static final String[] CONTACT_PROJECTION = new String[]{ContactRepository.ID_COLUMN,
|
||||
@@ -114,52 +115,91 @@ public class ContactsCursorLoader extends CursorLoader {
|
||||
private List<Cursor> getUnfilteredResults() {
|
||||
ArrayList<Cursor> cursorList = new ArrayList<>();
|
||||
|
||||
if (recents) {
|
||||
Cursor recentConversations = getRecentConversationsCursor();
|
||||
if (recentConversations.getCount() > 0) {
|
||||
cursorList.add(getRecentsHeaderCursor());
|
||||
cursorList.add(recentConversations);
|
||||
cursorList.add(getContactsHeaderCursor());
|
||||
}
|
||||
if (groupsOnly(mode)) {
|
||||
addRecentGroupsSection(cursorList);
|
||||
addGroupsSection(cursorList);
|
||||
} else {
|
||||
addRecentsSection(cursorList);
|
||||
addContactsSection(cursorList);
|
||||
}
|
||||
cursorList.addAll(getContactsCursors());
|
||||
|
||||
return cursorList;
|
||||
}
|
||||
|
||||
private List<Cursor> getFilteredResults() {
|
||||
ArrayList<Cursor> cursorList = new ArrayList<>();
|
||||
|
||||
if (groupsEnabled(mode)) {
|
||||
Cursor groups = getGroupsCursor();
|
||||
if (groups.getCount() > 0) {
|
||||
List<Cursor> contacts = getContactsCursors();
|
||||
if (!isCursorListEmpty(contacts)) {
|
||||
cursorList.add(getContactsHeaderCursor());
|
||||
cursorList.addAll(contacts);
|
||||
cursorList.add(getGroupsHeaderCursor());
|
||||
}
|
||||
cursorList.add(groups);
|
||||
} else {
|
||||
cursorList.addAll(getContactsCursors());
|
||||
}
|
||||
} else {
|
||||
cursorList.addAll(getContactsCursors());
|
||||
addContactsSection(cursorList);
|
||||
addGroupsSection(cursorList);
|
||||
addNewNumberSection(cursorList);
|
||||
addUsernameSearchSection(cursorList);
|
||||
|
||||
return cursorList;
|
||||
}
|
||||
|
||||
private void addRecentsSection(@NonNull List<Cursor> cursorList) {
|
||||
if (!recents) {
|
||||
return;
|
||||
}
|
||||
|
||||
Cursor recentConversations = getRecentConversationsCursor();
|
||||
|
||||
if (recentConversations.getCount() > 0) {
|
||||
cursorList.add(getRecentsHeaderCursor());
|
||||
cursorList.add(recentConversations);
|
||||
}
|
||||
}
|
||||
|
||||
private void addContactsSection(@NonNull List<Cursor> cursorList) {
|
||||
List<Cursor> contacts = getContactsCursors();
|
||||
|
||||
if (!isCursorListEmpty(contacts)) {
|
||||
cursorList.add(getContactsHeaderCursor());
|
||||
cursorList.addAll(getContactsCursors());
|
||||
}
|
||||
}
|
||||
|
||||
private void addRecentGroupsSection(@NonNull List<Cursor> cursorList) {
|
||||
if (!groupsEnabled(mode) || !recents) {
|
||||
return;
|
||||
}
|
||||
|
||||
Cursor groups = getRecentConversationsCursor(true);
|
||||
|
||||
if (groups.getCount() > 0) {
|
||||
cursorList.add(getRecentsHeaderCursor());
|
||||
cursorList.add(groups);
|
||||
}
|
||||
}
|
||||
|
||||
private void addGroupsSection(@NonNull List<Cursor> cursorList) {
|
||||
if (!groupsEnabled(mode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Cursor groups = getGroupsCursor();
|
||||
|
||||
if (groups.getCount() > 0) {
|
||||
cursorList.add(getGroupsHeaderCursor());
|
||||
cursorList.add(groups);
|
||||
}
|
||||
}
|
||||
|
||||
private void addNewNumberSection(@NonNull List<Cursor> cursorList) {
|
||||
if (FeatureFlags.usernames() && NumberUtil.isVisuallyValidNumberOrEmail(filter)) {
|
||||
cursorList.add(getPhoneNumberSearchHeaderCursor());
|
||||
cursorList.add(getNewNumberCursor());
|
||||
} else if (!FeatureFlags.usernames() && NumberUtil.isValidSmsOrEmail(filter)){
|
||||
cursorList.add(getContactsHeaderCursor());
|
||||
cursorList.add(getPhoneNumberSearchHeaderCursor());
|
||||
cursorList.add(getNewNumberCursor());
|
||||
}
|
||||
}
|
||||
|
||||
private void addUsernameSearchSection(@NonNull List<Cursor> cursorList) {
|
||||
if (FeatureFlags.usernames() && UsernameUtil.isValidUsernameForSearch(filter)) {
|
||||
cursorList.add(getUsernameSearchHeaderCursor());
|
||||
cursorList.add(getUsernameSearchCursor());
|
||||
}
|
||||
|
||||
return cursorList;
|
||||
}
|
||||
|
||||
private Cursor getRecentsHeaderCursor() {
|
||||
@@ -219,10 +259,14 @@ public class ContactsCursorLoader extends CursorLoader {
|
||||
|
||||
|
||||
private Cursor getRecentConversationsCursor() {
|
||||
return getRecentConversationsCursor(false);
|
||||
}
|
||||
|
||||
private Cursor getRecentConversationsCursor(boolean groupsOnly) {
|
||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(getContext());
|
||||
|
||||
MatrixCursor recentConversations = new MatrixCursor(CONTACT_PROJECTION, RECENT_CONVERSATION_MAX);
|
||||
try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS))) {
|
||||
try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), groupsOnly)) {
|
||||
ThreadDatabase.Reader reader = threadDatabase.readerFor(rawConversations);
|
||||
ThreadRecord threadRecord;
|
||||
while ((threadRecord = reader.getNext()) != null) {
|
||||
@@ -248,7 +292,7 @@ public class ContactsCursorLoader extends CursorLoader {
|
||||
}
|
||||
|
||||
if (pushEnabled(mode)) {
|
||||
cursorList.add(contactRepository.querySignalContacts(filter));
|
||||
cursorList.add(contactRepository.querySignalContacts(filter, selfEnabled(mode)));
|
||||
}
|
||||
|
||||
if (pushEnabled(mode) && smsEnabled(mode)) {
|
||||
@@ -278,7 +322,7 @@ public class ContactsCursorLoader extends CursorLoader {
|
||||
private Cursor getNewNumberCursor() {
|
||||
MatrixCursor newNumberCursor = new MatrixCursor(CONTACT_PROJECTION, 1);
|
||||
newNumberCursor.addRow(new Object[] { null,
|
||||
getContext().getString(R.string.contact_selection_list__unknown_contact),
|
||||
getUnknownContactTitle(),
|
||||
filter,
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
|
||||
"\u21e2",
|
||||
@@ -289,7 +333,7 @@ public class ContactsCursorLoader extends CursorLoader {
|
||||
private Cursor getUsernameSearchCursor() {
|
||||
MatrixCursor cursor = new MatrixCursor(CONTACT_PROJECTION, 1);
|
||||
cursor.addRow(new Object[] { null,
|
||||
getContext().getString(R.string.contact_selection_list__unknown_contact),
|
||||
getUnknownContactTitle(),
|
||||
filter,
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
|
||||
"\u21e2",
|
||||
@@ -297,6 +341,11 @@ public class ContactsCursorLoader extends CursorLoader {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private String getUnknownContactTitle() {
|
||||
return getContext().getString(newConversation(mode) ? R.string.contact_selection_list__unknown_contact
|
||||
: R.string.contact_selection_list__unknown_contact_add_to_group);
|
||||
}
|
||||
|
||||
private @NonNull Cursor filterNonPushContacts(@NonNull Cursor cursor) {
|
||||
try {
|
||||
final long startMillis = System.currentTimeMillis();
|
||||
@@ -329,6 +378,14 @@ public class ContactsCursorLoader extends CursorLoader {
|
||||
return sum == 0;
|
||||
}
|
||||
|
||||
private static boolean selfEnabled(int mode) {
|
||||
return flagSet(mode, DisplayMode.FLAG_SELF);
|
||||
}
|
||||
|
||||
private static boolean newConversation(int mode) {
|
||||
return groupsEnabled(mode);
|
||||
}
|
||||
|
||||
private static boolean pushEnabled(int mode) {
|
||||
return flagSet(mode, DisplayMode.FLAG_PUSH);
|
||||
}
|
||||
@@ -341,6 +398,10 @@ public class ContactsCursorLoader extends CursorLoader {
|
||||
return flagSet(mode, DisplayMode.FLAG_ACTIVE_GROUPS);
|
||||
}
|
||||
|
||||
private static boolean groupsOnly(int mode) {
|
||||
return mode == DisplayMode.FLAG_ACTIVE_GROUPS;
|
||||
}
|
||||
|
||||
private static boolean flagSet(int mode, int flag) {
|
||||
return (mode & flag) > 0;
|
||||
}
|
||||
|
||||
@@ -8,16 +8,12 @@ import androidx.annotation.Nullable;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Model for a contact and the various ways it could be represented. Used in situations where we
|
||||
* don't want to create Recipients for the wrapped data (like a custom-entered phone number for
|
||||
* someone you don't yet have a conversation with).
|
||||
*
|
||||
* Designed so that two instances will be equal if *any* of its properties match.
|
||||
*/
|
||||
public class SelectedContact {
|
||||
public final class SelectedContact {
|
||||
private final RecipientId recipientId;
|
||||
private final String number;
|
||||
private final String username;
|
||||
@@ -46,19 +42,20 @@ public class SelectedContact {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
SelectedContact that = (SelectedContact) o;
|
||||
/**
|
||||
* Returns true when non-null recipient ids match, and false if not.
|
||||
* <p>
|
||||
* If one or more recipient id is not set, then it returns true iff any other non-null property
|
||||
* matches one on the other contact.
|
||||
*/
|
||||
public boolean matches(@Nullable SelectedContact other) {
|
||||
if (other == null) return false;
|
||||
|
||||
return Objects.equals(recipientId, that.recipientId) ||
|
||||
Objects.equals(number, that.number) ||
|
||||
Objects.equals(username, that.username);
|
||||
}
|
||||
if (recipientId != null && other.recipientId != null) {
|
||||
return recipientId.equals(other.recipientId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(recipientId, number, username);
|
||||
return number != null && number .equals(other.number) ||
|
||||
username != null && username.equals(other.username);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Specialised set for {@link SelectedContact} that will not allow more than one entry that
|
||||
* {@link SelectedContact#matches(SelectedContact)} any other.
|
||||
*/
|
||||
public final class SelectedContactSet {
|
||||
|
||||
private final List<SelectedContact> contacts = new LinkedList<>();
|
||||
|
||||
public boolean add(@NonNull SelectedContact contact) {
|
||||
if (contains(contact)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
contacts.add(contact);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean contains(@NonNull SelectedContact otherContact) {
|
||||
for (SelectedContact contact : contacts) {
|
||||
if (otherContact.matches(contact)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<SelectedContact> getContacts() {
|
||||
return new ArrayList<>(contacts);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return contacts.size();
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
contacts.clear();
|
||||
}
|
||||
|
||||
public int remove(@NonNull SelectedContact otherContact) {
|
||||
int removeCount = 0;
|
||||
Iterator<SelectedContact> iterator = contacts.iterator();
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
SelectedContact next = iterator.next();
|
||||
if (next.matches(otherContact)) {
|
||||
iterator.remove();
|
||||
removeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return removeCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.InsetDrawable;
|
||||
import android.graphics.drawable.LayerDrawable;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class GroupFallbackPhoto80 implements FallbackContactPhoto {
|
||||
@Override
|
||||
public Drawable asDrawable(Context context, int color) {
|
||||
return buildDrawable(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asDrawable(Context context, int color, boolean inverted) {
|
||||
return buildDrawable(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asCallCard(Context context) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
private @NonNull Drawable buildDrawable(@NonNull Context context) {
|
||||
Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable)));
|
||||
Drawable foreground = AppCompatResources.getDrawable(context, R.drawable.ic_group_80);
|
||||
Drawable gradient = ThemeUtil.getThemedDrawable(context, R.attr.resource_placeholder_gradient);
|
||||
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient});
|
||||
int foregroundInset = ViewUtil.dpToPx(24);
|
||||
|
||||
DrawableCompat.setTint(background, MaterialColor.ULTRAMARINE.toAvatarColor(context));
|
||||
|
||||
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);
|
||||
|
||||
return drawable;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
@@ -57,6 +56,9 @@ import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
class DirectoryHelperV1 {
|
||||
|
||||
@@ -178,9 +180,9 @@ class DirectoryHelperV1 {
|
||||
if (insertResult.isPresent()) {
|
||||
int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
|
||||
if (hour >= 9 && hour < 23) {
|
||||
MessageNotifier.updateNotification(context, insertResult.get().getThreadId(), true);
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), true);
|
||||
} else {
|
||||
MessageNotifier.updateNotification(context, insertResult.get().getThreadId(), false);
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,10 +342,16 @@ class DirectoryHelperV1 {
|
||||
|
||||
private static boolean isUuidRegistered(@NonNull Context context, @NonNull Recipient recipient) throws IOException {
|
||||
try {
|
||||
ProfileUtil.retrieveProfile(context, recipient, SignalServiceProfile.RequestType.PROFILE);
|
||||
ProfileUtil.retrieveProfile(context, recipient, SignalServiceProfile.RequestType.PROFILE).get(10, TimeUnit.SECONDS);
|
||||
return true;
|
||||
} catch (NotFoundException e) {
|
||||
return false;
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof NotFoundException) {
|
||||
return false;
|
||||
} else {
|
||||
throw new IOException(e);
|
||||
}
|
||||
} catch (InterruptedException | TimeoutException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -643,7 +643,7 @@ public class Contact implements Parcelable {
|
||||
|
||||
private static Attachment attachmentFromUri(@Nullable Uri uri) {
|
||||
if (uri == null) return null;
|
||||
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, false, false, null, null, null, null);
|
||||
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, false, false, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -43,6 +43,7 @@ import android.provider.Telephony;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
@@ -125,6 +126,7 @@ import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.crypto.SecurityEvent;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -148,13 +150,22 @@ import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
|
||||
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
||||
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException;
|
||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors;
|
||||
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog;
|
||||
import org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesActivity;
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
|
||||
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||
import org.thoughtcrime.securesms.invites.InviteReminderModel;
|
||||
import org.thoughtcrime.securesms.invites.InviteReminderRepository;
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel;
|
||||
@@ -185,11 +196,11 @@ import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.mms.StickerSlide;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.profiles.GroupShareProfileView;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientExporter;
|
||||
@@ -235,8 +246,8 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -262,22 +273,22 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
ComposeText.CursorPositionChangedListener,
|
||||
ConversationSearchBottomBar.EventListener,
|
||||
StickerKeyboardProvider.StickerEventListener,
|
||||
AttachmentKeyboard.Callback
|
||||
AttachmentKeyboard.Callback,
|
||||
ConversationReactionOverlay.OnReactionSelectedListener,
|
||||
ReactWithAnyEmojiBottomSheetDialogFragment.Callback
|
||||
{
|
||||
|
||||
private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2);
|
||||
|
||||
private static final String TAG = ConversationActivity.class.getSimpleName();
|
||||
|
||||
public static final String RECIPIENT_EXTRA = "recipient_id";
|
||||
public static final String THREAD_ID_EXTRA = "thread_id";
|
||||
public static final String IS_ARCHIVED_EXTRA = "is_archived";
|
||||
public static final String TEXT_EXTRA = "draft_text";
|
||||
public static final String MEDIA_EXTRA = "media_list";
|
||||
public static final String STICKER_EXTRA = "sticker_extra";
|
||||
public static final String DISTRIBUTION_TYPE_EXTRA = "distribution_type";
|
||||
public static final String LAST_SEEN_EXTRA = "last_seen";
|
||||
public static final String STARTING_POSITION_EXTRA = "starting_position";
|
||||
public static final String RECIPIENT_EXTRA = "recipient_id";
|
||||
public static final String THREAD_ID_EXTRA = "thread_id";
|
||||
public static final String TEXT_EXTRA = "draft_text";
|
||||
public static final String MEDIA_EXTRA = "media_list";
|
||||
public static final String STICKER_EXTRA = "sticker_extra";
|
||||
public static final String DISTRIBUTION_TYPE_EXTRA = "distribution_type";
|
||||
public static final String STARTING_POSITION_EXTRA = "starting_position";
|
||||
|
||||
private static final int PICK_GALLERY = 1;
|
||||
private static final int PICK_DOCUMENT = 2;
|
||||
@@ -328,16 +339,15 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
private ConversationStickerViewModel stickerViewModel;
|
||||
private ConversationViewModel viewModel;
|
||||
private InviteReminderModel inviteReminderModel;
|
||||
private ConversationGroupViewModel groupViewModel;
|
||||
|
||||
private LiveRecipient recipient;
|
||||
private long threadId;
|
||||
private int distributionType;
|
||||
private boolean archived;
|
||||
private boolean isSecureText;
|
||||
private boolean isDefaultSms = true;
|
||||
private boolean isMmsEnabled = true;
|
||||
private boolean isSecurityInitialized = false;
|
||||
private boolean shouldDisplayMessageRequestUi = true;
|
||||
|
||||
private final IdentityRecordList identityRecords = new IdentityRecordList();
|
||||
private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme();
|
||||
@@ -347,14 +357,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
@NonNull RecipientId recipientId,
|
||||
long threadId,
|
||||
int distributionType,
|
||||
long lastSeen,
|
||||
int startingPosition)
|
||||
{
|
||||
Intent intent = new Intent(context, ConversationActivity.class);
|
||||
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId);
|
||||
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
||||
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
|
||||
intent.putExtra(ConversationActivity.LAST_SEEN_EXTRA, lastSeen);
|
||||
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, startingPosition);
|
||||
|
||||
return intent;
|
||||
@@ -397,6 +405,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
initializeSearchObserver();
|
||||
initializeStickerObserver();
|
||||
initializeViewModel();
|
||||
initializeGroupViewModel();
|
||||
initializeEnabledCheck();
|
||||
initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
@@ -476,7 +486,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
dynamicLanguage.onResume(this);
|
||||
|
||||
EventBus.getDefault().register(this);
|
||||
initializeEnabledCheck();
|
||||
initializeMmsEnabledCheck();
|
||||
initializeIdentityRecords();
|
||||
composeText.setTransport(sendButton.getSelectedTransport());
|
||||
@@ -489,14 +498,18 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
setGroupShareProfileReminder(recipientSnapshot);
|
||||
calculateCharactersRemaining();
|
||||
|
||||
MessageNotifier.setVisibleThread(threadId);
|
||||
if (recipientSnapshot.getGroupId().isPresent() && recipientSnapshot.getGroupId().get().isV2()) {
|
||||
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(recipientSnapshot.getGroupId().get().requireV2()));
|
||||
}
|
||||
|
||||
ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId);
|
||||
markThreadAsRead();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
MessageNotifier.setVisibleThread(-1L);
|
||||
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
|
||||
if (isFinishing()) overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_end);
|
||||
inputPanel.onPause();
|
||||
|
||||
@@ -581,8 +594,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
handleImageFromDeviceCameraApp();
|
||||
break;
|
||||
case ADD_CONTACT:
|
||||
onRecipientChanged(recipient.get());
|
||||
fragment.reloadList();
|
||||
SimpleTask.run(() -> {
|
||||
try {
|
||||
DirectoryHelper.refreshDirectoryFor(this, recipient.get(), false);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to refresh user after adding to contacts.");
|
||||
}
|
||||
return null;
|
||||
}, nothing -> onRecipientChanged(recipient.get()));
|
||||
break;
|
||||
case PICK_LOCATION:
|
||||
SignalPlace place = new SignalPlace(PlacePickerActivity.addressFromData(data));
|
||||
@@ -692,8 +711,13 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
MenuInflater inflater = this.getMenuInflater();
|
||||
menu.clear();
|
||||
|
||||
GroupActiveState groupActiveState = groupViewModel.getGroupActiveState().getValue();
|
||||
boolean isActiveGroup = groupActiveState != null && groupActiveState.isActiveGroup();
|
||||
boolean isActiveV2Group = groupActiveState != null && groupActiveState.isActiveV2Group();
|
||||
boolean isInActiveGroup = groupActiveState != null && !groupActiveState.isActiveGroup();
|
||||
|
||||
if (isInMessageRequest()) {
|
||||
if (isActiveGroup()) {
|
||||
if (isActiveGroup) {
|
||||
inflater.inflate(R.menu.conversation_message_requests_group, menu);
|
||||
}
|
||||
|
||||
@@ -708,10 +732,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
|
||||
if (isSecureText) {
|
||||
if (recipient.get().getExpireMessages() > 0) {
|
||||
inflater.inflate(R.menu.conversation_expiring_on, menu);
|
||||
if (!isInActiveGroup) {
|
||||
inflater.inflate(R.menu.conversation_expiring_on, menu);
|
||||
}
|
||||
titleView.showExpiring(recipient);
|
||||
} else {
|
||||
inflater.inflate(R.menu.conversation_expiring_off, menu);
|
||||
if (!isInActiveGroup) {
|
||||
inflater.inflate(R.menu.conversation_expiring_off, menu);
|
||||
}
|
||||
titleView.clearExpiring();
|
||||
}
|
||||
}
|
||||
@@ -729,9 +757,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
} else {
|
||||
menu.findItem(R.id.menu_distribution_conversation).setChecked(true);
|
||||
}
|
||||
} else if (isActiveV2Group()) {
|
||||
} else if (isActiveV2Group || isActiveGroup && FeatureFlags.newGroupUI()) {
|
||||
inflater.inflate(R.menu.conversation_push_group_v2_options, menu);
|
||||
} else if (isActiveGroup()) {
|
||||
} else if (isActiveGroup) {
|
||||
inflater.inflate(R.menu.conversation_push_group_options, menu);
|
||||
}
|
||||
}
|
||||
@@ -753,36 +781,37 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
|
||||
if (recipient != null && recipient.get().isLocalNumber()) {
|
||||
if (isSecureText) {
|
||||
menu.findItem(R.id.menu_call_secure).setVisible(false);
|
||||
menu.findItem(R.id.menu_video_secure).setVisible(false);
|
||||
hideMenuItem(menu, R.id.menu_call_secure);
|
||||
hideMenuItem(menu, R.id.menu_video_secure);
|
||||
} else {
|
||||
menu.findItem(R.id.menu_call_insecure).setVisible(false);
|
||||
hideMenuItem(menu, R.id.menu_call_insecure);
|
||||
}
|
||||
|
||||
MenuItem muteItem = menu.findItem(R.id.menu_mute_notifications);
|
||||
|
||||
if (muteItem != null) {
|
||||
muteItem.setVisible(false);
|
||||
}
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications);
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.get().isBlocked()) {
|
||||
if (isSecureText) {
|
||||
menu.findItem(R.id.menu_call_secure).setVisible(false);
|
||||
menu.findItem(R.id.menu_video_secure).setVisible(false);
|
||||
|
||||
if (menu.findItem(R.id.menu_expiring_messages) != null) {
|
||||
menu.findItem(R.id.menu_expiring_messages).setVisible(false);
|
||||
}
|
||||
|
||||
if (menu.findItem(R.id.menu_expiring_messages_off) != null) {
|
||||
menu.findItem(R.id.menu_expiring_messages_off).setVisible(false);
|
||||
}
|
||||
hideMenuItem(menu, R.id.menu_call_secure);
|
||||
hideMenuItem(menu, R.id.menu_video_secure);
|
||||
hideMenuItem(menu, R.id.menu_expiring_messages);
|
||||
hideMenuItem(menu, R.id.menu_expiring_messages_off);
|
||||
} else {
|
||||
menu.findItem(R.id.menu_call_insecure).setVisible(false);
|
||||
hideMenuItem(menu, R.id.menu_call_insecure);
|
||||
}
|
||||
|
||||
menu.findItem(R.id.menu_mute_notifications).setVisible(false);
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications);
|
||||
}
|
||||
|
||||
if (FeatureFlags.newGroupUI() && isPushGroupConversation()) {
|
||||
hideMenuItem(menu, R.id.menu_group_recipients);
|
||||
}
|
||||
|
||||
if (isActiveV2Group) {
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications);
|
||||
hideMenuItem(menu, R.id.menu_conversation_settings);
|
||||
} else if (isActiveGroup) {
|
||||
hideMenuItem(menu, R.id.menu_conversation_settings);
|
||||
}
|
||||
|
||||
searchViewItem = menu.findItem(R.id.menu_search);
|
||||
@@ -855,8 +884,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
case R.id.menu_group_recipients: handleDisplayGroupRecipients(); return true;
|
||||
case R.id.menu_distribution_broadcast: handleDistributionBroadcastEnabled(item); return true;
|
||||
case R.id.menu_distribution_conversation: handleDistributionConversationEnabled(item); return true;
|
||||
case R.id.menu_edit_group: handleEditPushGroup(); return true;
|
||||
case R.id.menu_pending_members: handlePendingMembers(); return true;
|
||||
case R.id.menu_edit_group: handleEditPushGroupV1(); return true;
|
||||
case R.id.menu_group_settings: handleManagePushGroup(); return true;
|
||||
case R.id.menu_leave: handleLeavePushGroup(); return true;
|
||||
case R.id.menu_invite: handleInviteLink(); return true;
|
||||
case R.id.menu_mute_notifications: handleMuteNotifications(); return true;
|
||||
@@ -864,12 +893,34 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
case R.id.menu_conversation_settings: handleConversationSettings(); return true;
|
||||
case R.id.menu_expiring_messages_off:
|
||||
case R.id.menu_expiring_messages: handleSelectMessageExpiration(); return true;
|
||||
case android.R.id.home: onBackPressed(); return true;
|
||||
case android.R.id.home: onNavigateUp(); return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuOpened(int featureId, Menu menu) {
|
||||
if (menu == null) {
|
||||
return super.onMenuOpened(featureId, null);
|
||||
}
|
||||
|
||||
if (!SignalStore.uiHints().hasSeenGroupSettingsMenuToast()) {
|
||||
MenuItem settingsMenuItem = menu.findItem(R.id.menu_group_settings);
|
||||
|
||||
if (settingsMenuItem != null && settingsMenuItem.isVisible()) {
|
||||
Toast toast = Toast.makeText(this, R.string.ConversationActivity__more_options_now_in_group_settings, Toast.LENGTH_SHORT);
|
||||
|
||||
toast.setGravity(Gravity.CENTER, 0, 0);
|
||||
toast.show();
|
||||
|
||||
SignalStore.uiHints().markHasSeenGroupSettingsMenuToast();
|
||||
}
|
||||
}
|
||||
|
||||
return super.onMenuOpened(featureId, menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
Log.d(TAG, "onBackPressed()");
|
||||
@@ -934,29 +985,46 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
//////// Event Handlers
|
||||
|
||||
private void handleSelectMessageExpiration() {
|
||||
if (isPushGroupConversation() && !isActiveGroup()) {
|
||||
boolean activeGroup = isActiveGroup();
|
||||
|
||||
if (isPushGroupConversation() && !activeGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
//noinspection CodeBlock2Expr
|
||||
ExpirationDialog.show(this, recipient.get().getExpireMessages(), expirationTime -> {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
DatabaseFactory.getRecipientDatabase(ConversationActivity.this).setExpireMessages(recipient.getId(), expirationTime);
|
||||
OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(getRecipient(), System.currentTimeMillis(), expirationTime * 1000L);
|
||||
MessageSender.send(ConversationActivity.this, outgoingMessage, threadId, false, null);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Void result) {
|
||||
invalidateOptionsMenu();
|
||||
if (fragment != null) fragment.setLastSeen(0);
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
});
|
||||
ExpirationDialog.show(this, recipient.get().getExpireMessages(),
|
||||
expirationTime ->
|
||||
SimpleTask.run(
|
||||
getLifecycle(),
|
||||
() -> {
|
||||
if (activeGroup) {
|
||||
try {
|
||||
GroupManager.updateGroupTimer(ConversationActivity.this, getRecipient().requireGroupId().requirePush(), expirationTime);
|
||||
} catch (GroupInsufficientRightsException e) {
|
||||
Log.w(TAG, e);
|
||||
return ConversationActivity.this.getString(R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this);
|
||||
} catch (GroupNotAMemberException e) {
|
||||
Log.w(TAG, e);
|
||||
return ConversationActivity.this.getString(R.string.ManageGroupActivity_youre_not_a_member_of_the_group);
|
||||
} catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return ConversationActivity.this.getString(R.string.ManageGroupActivity_failed_to_update_the_group);
|
||||
}
|
||||
} else {
|
||||
DatabaseFactory.getRecipientDatabase(ConversationActivity.this).setExpireMessages(recipient.getId(), expirationTime);
|
||||
OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(getRecipient(), System.currentTimeMillis(), expirationTime * 1000L);
|
||||
MessageSender.send(ConversationActivity.this, outgoingMessage, threadId, false, null);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(errorString) -> {
|
||||
if (errorString != null) {
|
||||
Toast.makeText(ConversationActivity.this, errorString, Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
invalidateOptionsMenu();
|
||||
if (fragment != null) fragment.setLastSeen(0);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void handleMuteNotifications() {
|
||||
@@ -974,6 +1042,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private void handleConversationSettings() {
|
||||
if (FeatureFlags.newGroupUI() && isPushGroupConversation()) {
|
||||
handleManagePushGroup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInMessageRequest()) return;
|
||||
|
||||
Intent intent = RecipientPreferenceActivity.getLaunchIntent(this, recipient.getId());
|
||||
@@ -1130,17 +1203,17 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
LeaveGroupDialog.handleLeavePushGroup(ConversationActivity.this,
|
||||
getLifecycle(),
|
||||
getRecipient().requireGroupId().requirePush(),
|
||||
this::initializeEnabledCheck);
|
||||
null);
|
||||
}
|
||||
|
||||
private void handleEditPushGroup() {
|
||||
Intent intent = new Intent(ConversationActivity.this, GroupCreateActivity.class);
|
||||
intent.putExtra(GroupCreateActivity.GROUP_ID_EXTRA, recipient.get().requireGroupId().toString());
|
||||
startActivityForResult(intent, GROUP_EDIT);
|
||||
private void handleEditPushGroupV1() {
|
||||
startActivityForResult(GroupCreateActivity.newEditGroupIntent(ConversationActivity.this, recipient.get().requireGroupId().requireV1()), GROUP_EDIT);
|
||||
}
|
||||
|
||||
private void handlePendingMembers() {
|
||||
startActivity(PendingMemberInvitesActivity.newIntent(ConversationActivity.this, recipient.get().requireGroupId().requireV2()));
|
||||
private void handleManagePushGroup() {
|
||||
startActivityForResult(ManageGroupActivity.newIntent(ConversationActivity.this, recipient.get().requireGroupId().requirePush()),
|
||||
GROUP_EDIT,
|
||||
ManageGroupActivity.createTransitionBundle(this, titleView.findViewById(R.id.contact_photo_image)));
|
||||
}
|
||||
|
||||
private void handleDistributionBroadcastEnabled(MenuItem item) {
|
||||
@@ -1192,7 +1265,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private void handleDisplayGroupRecipients() {
|
||||
new GroupMembersDialog(this, getRecipient(), getLifecycle()).display();
|
||||
new GroupMembersDialog(this, getRecipient()).display();
|
||||
}
|
||||
|
||||
private void handleAddToContacts() {
|
||||
@@ -1245,7 +1318,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private void handleUnverifiedRecipients() {
|
||||
List<Recipient> unverifiedRecipients = identityRecords.getUnverifiedRecipients(this);
|
||||
List<Recipient> unverifiedRecipients = identityRecords.getUnverifiedRecipients();
|
||||
List<IdentityRecord> unverifiedRecords = identityRecords.getUnverifiedRecords();
|
||||
String message = IdentityUtil.getUnverifiedSendDialogDescription(this, unverifiedRecipients);
|
||||
|
||||
@@ -1268,7 +1341,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private void handleUntrustedRecipients() {
|
||||
List<Recipient> untrustedRecipients = identityRecords.getUntrustedRecipients(this);
|
||||
List<Recipient> untrustedRecipients = identityRecords.getUntrustedRecipients();
|
||||
List<IdentityRecord> untrustedRecords = identityRecords.getUntrustedRecords();
|
||||
String untrustedMessage = IdentityUtil.getUntrustedSendDialogDescription(this, untrustedRecipients);
|
||||
|
||||
@@ -1362,10 +1435,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private void initializeEnabledCheck() {
|
||||
boolean enabled = !(isPushGroupConversation() && !isActiveGroup());
|
||||
inputPanel.setEnabled(enabled);
|
||||
sendButton.setEnabled(enabled);
|
||||
attachButton.setEnabled(enabled);
|
||||
groupViewModel.getGroupActiveState().observe(this, state -> {
|
||||
boolean enabled = state == null || !(isPushGroupConversation() && !state.isActiveGroup());
|
||||
inputPanel.setEnabled(enabled);
|
||||
sendButton.setEnabled(enabled);
|
||||
attachButton.setEnabled(enabled);
|
||||
});
|
||||
}
|
||||
|
||||
private ListenableFuture<Boolean> initializeDraftFromDatabase() {
|
||||
@@ -1567,26 +1642,25 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
new AsyncTask<Recipient, Void, Pair<IdentityRecordList, String>>() {
|
||||
@Override
|
||||
protected @NonNull Pair<IdentityRecordList, String> doInBackground(Recipient... params) {
|
||||
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(ConversationActivity.this);
|
||||
IdentityRecordList identityRecordList = new IdentityRecordList();
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(ConversationActivity.this);
|
||||
List<Recipient> recipients;
|
||||
|
||||
if (params[0].isGroup()) {
|
||||
recipients.addAll(DatabaseFactory.getGroupDatabase(ConversationActivity.this)
|
||||
.getGroupMembers(params[0].requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF));
|
||||
recipients = DatabaseFactory.getGroupDatabase(ConversationActivity.this)
|
||||
.getGroupMembers(params[0].requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF);
|
||||
} else {
|
||||
recipients.add(params[0]);
|
||||
recipients = Collections.singletonList(params[0]);
|
||||
}
|
||||
|
||||
for (Recipient recipient : recipients) {
|
||||
Log.i(TAG, "Loading identity for: " + recipient.getId());
|
||||
identityRecordList.add(identityDatabase.getIdentity(recipient.getId()));
|
||||
}
|
||||
long startTime = System.currentTimeMillis();
|
||||
IdentityRecordList identityRecordList = identityDatabase.getIdentities(recipients);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Loaded %d identities in %d ms", recipients.size(), System.currentTimeMillis() - startTime));
|
||||
|
||||
String message = null;
|
||||
|
||||
if (identityRecordList.isUnverified()) {
|
||||
message = IdentityUtil.getUnverifiedBannerDescription(ConversationActivity.this, identityRecordList.getUnverifiedRecipients(ConversationActivity.this));
|
||||
message = IdentityUtil.getUnverifiedBannerDescription(ConversationActivity.this, identityRecordList.getUnverifiedRecipients());
|
||||
}
|
||||
|
||||
return new Pair<>(identityRecordList, message);
|
||||
@@ -1695,7 +1769,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
|
||||
inlineAttachmentButton.setOnClickListener(v -> handleAddAttachment());
|
||||
|
||||
reactionOverlay.setOnReactionSelectedListener(this::onReactionSelected);
|
||||
reactionOverlay.setOnReactionSelectedListener(this);
|
||||
}
|
||||
|
||||
protected void initializeActionBar() {
|
||||
@@ -1716,7 +1790,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
|
||||
recipient = Recipient.live(getIntent().getParcelableExtra(RECIPIENT_EXTRA));
|
||||
threadId = getIntent().getLongExtra(THREAD_ID_EXTRA, -1);
|
||||
archived = getIntent().getBooleanExtra(IS_ARCHIVED_EXTRA, false);
|
||||
distributionType = getIntent().getIntExtra(DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT);
|
||||
glideRequests = GlideApp.with(this);
|
||||
|
||||
@@ -1797,6 +1870,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
this.viewModel = ViewModelProviders.of(this, new ConversationViewModel.Factory()).get(ConversationViewModel.class);
|
||||
}
|
||||
|
||||
private void initializeGroupViewModel() {
|
||||
groupViewModel = ViewModelProviders.of(this, new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
|
||||
recipient.observe(this, groupViewModel::onRecipientChange);
|
||||
groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu());
|
||||
}
|
||||
|
||||
private void showStickerIntroductionTooltip() {
|
||||
TextSecurePreferences.setMediaKeyboardMode(this, MediaKeyboardMode.STICKER);
|
||||
inputPanel.setMediaKeyboardToggleMode(true);
|
||||
@@ -1812,10 +1891,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
}
|
||||
|
||||
|
||||
private void onReactionSelected(MessageRecord messageRecord, String emoji) {
|
||||
@Override
|
||||
public void onReactionSelected(MessageRecord messageRecord, String emoji) {
|
||||
final Context context = getApplicationContext();
|
||||
|
||||
reactionOverlay.hide();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
ReactionRecord oldRecord = Stream.of(messageRecord.getReactions())
|
||||
.filter(record -> record.getAuthor().equals(Recipient.self().getId()))
|
||||
@@ -1830,6 +1911,35 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji) {
|
||||
ReactionRecord oldRecord = Stream.of(messageRecord.getReactions())
|
||||
.filter(record -> record.getAuthor().equals(Recipient.self().getId()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (oldRecord != null && hasAddedCustomEmoji) {
|
||||
final Context context = getApplicationContext();
|
||||
|
||||
reactionOverlay.hide();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> MessageSender.sendReactionRemoval(context,
|
||||
messageRecord.getId(),
|
||||
messageRecord.isMms(),
|
||||
oldRecord));
|
||||
} else {
|
||||
reactionOverlay.hideAllButMask();
|
||||
|
||||
ReactWithAnyEmojiBottomSheetDialogFragment.createForMessageRecord(messageRecord)
|
||||
.show(getSupportFragmentManager(), "BOTTOM");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReactWithAnyEmojiDialogDismissed() {
|
||||
reactionOverlay.hideMask();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearchMoveUpPressed() {
|
||||
searchViewModel.onMoveUp();
|
||||
@@ -1846,7 +1956,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
return;
|
||||
}
|
||||
|
||||
ApplicationDependencies.getJobManager().add(RetrieveProfileJob.forRecipient(recipient.get()));
|
||||
RetrieveProfileJob.enqueueAsync(recipient.getId());
|
||||
}
|
||||
|
||||
private void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
@@ -1863,6 +1973,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
if (searchViewItem == null || !searchViewItem.isActionViewExpanded()) {
|
||||
invalidateOptionsMenu();
|
||||
}
|
||||
|
||||
if (groupViewModel != null) {
|
||||
groupViewModel.onRecipientChange(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
@@ -2120,13 +2234,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
return record.isPresent() && record.get().isActive();
|
||||
}
|
||||
|
||||
private boolean isActiveV2Group() {
|
||||
if (!isGroupConversation()) return false;
|
||||
|
||||
Optional<GroupRecord> record = DatabaseFactory.getGroupDatabase(this).getGroup(getRecipient().getId());
|
||||
return record.isPresent() && record.get().isActive() && record.get().isV2Group();
|
||||
}
|
||||
|
||||
@SuppressWarnings("SimplifiableIfStatement")
|
||||
private boolean isSelfConversation() {
|
||||
if (!TextSecurePreferences.isPushRegistered(this)) return false;
|
||||
@@ -2143,6 +2250,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
return getRecipient() != null && getRecipient().isPushGroup();
|
||||
}
|
||||
|
||||
private boolean isPushGroupV1Conversation() {
|
||||
return getRecipient() != null && getRecipient().isPushV1Group();
|
||||
}
|
||||
|
||||
private boolean isSmsForced() {
|
||||
return sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms();
|
||||
}
|
||||
@@ -2177,7 +2288,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
Context context = ConversationActivity.this;
|
||||
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setRead(params[0], false);
|
||||
|
||||
MessageNotifier.updateNotification(context);
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context);
|
||||
MarkReadReceiver.process(context, messageIds);
|
||||
|
||||
return null;
|
||||
@@ -2207,7 +2318,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
|
||||
if (refreshFragment) {
|
||||
fragment.reload(recipient.get(), threadId);
|
||||
MessageNotifier.setVisibleThread(threadId);
|
||||
ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId);
|
||||
}
|
||||
|
||||
fragment.scrollToBottom();
|
||||
@@ -2778,7 +2889,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
|
||||
@Override
|
||||
public void onMessageRequest(@NonNull MessageRequestViewModel viewModel) {
|
||||
messageRequestBottomView.setAcceptOnClickListener(v -> viewModel.onAccept());
|
||||
messageRequestBottomView.setAcceptOnClickListener(v -> viewModel.onAccept(this::showGroupChangeErrorToast));
|
||||
messageRequestBottomView.setDeleteOnClickListener(v -> onMessageRequestDeleteClicked(viewModel));
|
||||
messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel));
|
||||
messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel));
|
||||
@@ -2797,6 +2908,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
});
|
||||
}
|
||||
|
||||
private void showGroupChangeErrorToast(@NonNull GroupChangeFailureReason e) {
|
||||
Toast.makeText(this, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleReaction(@NonNull View maskTarget,
|
||||
@NonNull MessageRecord messageRecord,
|
||||
@@ -2958,9 +3073,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
private void presentMessageRequestDisplayState(@NonNull MessageRequestViewModel.DisplayState displayState) {
|
||||
if (getIntent().hasExtra(TEXT_EXTRA) || getIntent().hasExtra(MEDIA_EXTRA) || getIntent().hasExtra(STICKER_EXTRA) || (isPushGroupConversation() && !isActiveGroup())) {
|
||||
if (getIntent().hasExtra(TEXT_EXTRA) || getIntent().hasExtra(MEDIA_EXTRA) || getIntent().hasExtra(STICKER_EXTRA)) {
|
||||
Log.d(TAG, "[presentMessageRequestDisplayState] Have extra, so ignoring provided state.");
|
||||
messageRequestBottomView.setVisibility(View.GONE);
|
||||
} else if (isPushGroupV1Conversation() && !isActiveGroup()) {
|
||||
Log.d(TAG, "[presentMessageRequestDisplayState] Inactive push group V1, so ignoring provided state.");
|
||||
messageRequestBottomView.setVisibility(View.GONE);
|
||||
} else {
|
||||
Log.d(TAG, "[presentMessageRequestDisplayState] " + displayState);
|
||||
switch (displayState) {
|
||||
@@ -2989,6 +3107,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
invalidateOptionsMenu();
|
||||
}
|
||||
|
||||
private static void hideMenuItem(@NonNull Menu menu, @IdRes int menuItem) {
|
||||
if (menu.findItem(menuItem) != null) {
|
||||
menu.findItem(menuItem).setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
private class UnverifiedDismissedListener implements UnverifiedBannerView.DismissListener {
|
||||
@Override
|
||||
public void onDismissed(final List<IdentityRecord> unverifiedIdentities) {
|
||||
|
||||
@@ -16,117 +16,520 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.paging.PagedList;
|
||||
import androidx.paging.PagedListAdapter;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.HeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.FastCursorRecyclerViewAdapter;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.Conversions;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.LRUCache;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A cursor adapter for a conversation thread. Ultimately
|
||||
* used by ComposeMessageActivity to display a conversation
|
||||
* thread in a ListActivity.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
* Adapter that renders a conversation.
|
||||
*
|
||||
* Important spacial thing to keep in mind: The adapter is intended to be shown on a reversed layout
|
||||
* manager, so position 0 is at the bottom of the screen. That's why the "header" is at the bottom,
|
||||
* the "footer" is at the top, and we refer to the "next" record as having a lower index.
|
||||
*/
|
||||
public class ConversationAdapter <V extends View & BindableConversationItem>
|
||||
extends FastCursorRecyclerViewAdapter<ConversationAdapter.ViewHolder, MessageRecord>
|
||||
implements StickyHeaderDecoration.StickyHeaderAdapter<HeaderViewHolder>
|
||||
public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
extends PagedListAdapter<MessageRecord, RecyclerView.ViewHolder>
|
||||
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationAdapter.StickyHeaderViewHolder>
|
||||
{
|
||||
|
||||
private static final int MAX_CACHE_SIZE = 40;
|
||||
private static final String TAG = ConversationAdapter.class.getSimpleName();
|
||||
private final Map<String,SoftReference<MessageRecord>> messageRecordCache =
|
||||
Collections.synchronizedMap(new LRUCache<String, SoftReference<MessageRecord>>(MAX_CACHE_SIZE));
|
||||
private static final String TAG = Log.tag(ConversationAdapter.class);
|
||||
|
||||
private static final int MESSAGE_TYPE_OUTGOING = 0;
|
||||
private static final int MESSAGE_TYPE_INCOMING = 1;
|
||||
private static final int MESSAGE_TYPE_UPDATE = 2;
|
||||
private static final int MESSAGE_TYPE_AUDIO_OUTGOING = 3;
|
||||
private static final int MESSAGE_TYPE_AUDIO_INCOMING = 4;
|
||||
private static final int MESSAGE_TYPE_THUMBNAIL_OUTGOING = 5;
|
||||
private static final int MESSAGE_TYPE_THUMBNAIL_INCOMING = 6;
|
||||
private static final int MESSAGE_TYPE_DOCUMENT_OUTGOING = 7;
|
||||
private static final int MESSAGE_TYPE_DOCUMENT_INCOMING = 8;
|
||||
private static final int MESSAGE_TYPE_OUTGOING_MULTIMEDIA = 0;
|
||||
private static final int MESSAGE_TYPE_OUTGOING_TEXT = 1;
|
||||
private static final int MESSAGE_TYPE_INCOMING_MULTIMEDIA = 2;
|
||||
private static final int MESSAGE_TYPE_INCOMING_TEXT = 3;
|
||||
private static final int MESSAGE_TYPE_UPDATE = 4;
|
||||
private static final int MESSAGE_TYPE_HEADER = 5;
|
||||
private static final int MESSAGE_TYPE_FOOTER = 6;
|
||||
private static final int MESSAGE_TYPE_PLACEHOLDER = 7;
|
||||
|
||||
private final Set<MessageRecord> batchSelected = Collections.synchronizedSet(new HashSet<MessageRecord>());
|
||||
private static final long HEADER_ID = Long.MIN_VALUE;
|
||||
private static final long FOOTER_ID = Long.MIN_VALUE + 1;
|
||||
|
||||
private final @Nullable ItemClickListener clickListener;
|
||||
private final @NonNull GlideRequests glideRequests;
|
||||
private final @NonNull Locale locale;
|
||||
private final @NonNull Recipient recipient;
|
||||
private final @NonNull MmsSmsDatabase db;
|
||||
private final @NonNull LayoutInflater inflater;
|
||||
private final @NonNull Calendar calendar;
|
||||
private final @NonNull MessageDigest digest;
|
||||
private final ItemClickListener clickListener;
|
||||
private final GlideRequests glideRequests;
|
||||
private final Locale locale;
|
||||
private final Recipient recipient;
|
||||
|
||||
private final Set<MessageRecord> selected;
|
||||
private final List<MessageRecord> fastRecords;
|
||||
private final Set<Long> releasedFastRecords;
|
||||
private final Calendar calendar;
|
||||
private final MessageDigest digest;
|
||||
|
||||
private MessageRecord recordToPulseHighlight;
|
||||
private String searchQuery;
|
||||
private MessageRecord recordToPulseHighlight;
|
||||
private View headerView;
|
||||
private View footerView;
|
||||
|
||||
protected static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
public <V extends View & BindableConversationItem> ViewHolder(final @NonNull V itemView) {
|
||||
ConversationAdapter(@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@Nullable ItemClickListener clickListener,
|
||||
@NonNull Recipient recipient)
|
||||
{
|
||||
super(new DiffCallback());
|
||||
|
||||
this.glideRequests = glideRequests;
|
||||
this.locale = locale;
|
||||
this.clickListener = clickListener;
|
||||
this.recipient = recipient;
|
||||
this.selected = new HashSet<>();
|
||||
this.fastRecords = new ArrayList<>();
|
||||
this.releasedFastRecords = new HashSet<>();
|
||||
this.calendar = Calendar.getInstance();
|
||||
this.digest = getMessageDigestOrThrow();
|
||||
|
||||
setHasStableIds(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
if (hasHeader() && position == 0) {
|
||||
return MESSAGE_TYPE_HEADER;
|
||||
}
|
||||
|
||||
if (hasFooter() && position == getItemCount() - 1) {
|
||||
return MESSAGE_TYPE_FOOTER;
|
||||
}
|
||||
|
||||
MessageRecord messageRecord = getItem(position);
|
||||
|
||||
if (messageRecord == null) {
|
||||
return MESSAGE_TYPE_PLACEHOLDER;
|
||||
} else if (messageRecord.isUpdate()) {
|
||||
return MESSAGE_TYPE_UPDATE;
|
||||
} else if (messageRecord.isOutgoing()) {
|
||||
return messageRecord.isMms() ? MESSAGE_TYPE_OUTGOING_MULTIMEDIA : MESSAGE_TYPE_OUTGOING_TEXT;
|
||||
} else {
|
||||
return messageRecord.isMms() ? MESSAGE_TYPE_INCOMING_MULTIMEDIA : MESSAGE_TYPE_INCOMING_TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
if (hasHeader() && position == 0) {
|
||||
return HEADER_ID;
|
||||
}
|
||||
|
||||
if (hasFooter() && position == getItemCount() - 1) {
|
||||
return FOOTER_ID;
|
||||
}
|
||||
|
||||
MessageRecord record = getItem(position);
|
||||
|
||||
if (record == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
String unique = (record.isMms() ? "MMS::" : "SMS::") + record.getId();
|
||||
byte[] bytes = digest.digest(unique.getBytes());
|
||||
|
||||
return Conversions.byteArrayToLong(bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
switch (viewType) {
|
||||
case MESSAGE_TYPE_INCOMING_TEXT:
|
||||
case MESSAGE_TYPE_INCOMING_MULTIMEDIA:
|
||||
case MESSAGE_TYPE_OUTGOING_TEXT:
|
||||
case MESSAGE_TYPE_OUTGOING_MULTIMEDIA:
|
||||
case MESSAGE_TYPE_UPDATE:
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
V itemView = CachedInflater.from(parent.getContext()).inflate(getLayoutForViewType(viewType), parent, false);
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (clickListener != null) {
|
||||
clickListener.onItemClick(itemView.getMessageRecord());
|
||||
}
|
||||
});
|
||||
|
||||
itemView.setOnLongClickListener(view -> {
|
||||
if (clickListener != null) {
|
||||
clickListener.onItemLongClick(itemView, itemView.getMessageRecord());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
itemView.setEventListener(clickListener);
|
||||
|
||||
Log.d(TAG, String.format(Locale.US, "Inflate time: %d ms for View type: %d", System.currentTimeMillis() - start, viewType));
|
||||
return new ConversationViewHolder(itemView);
|
||||
case MESSAGE_TYPE_PLACEHOLDER:
|
||||
View v = new FrameLayout(parent.getContext());
|
||||
v.setLayoutParams(new FrameLayout.LayoutParams(1, ViewUtil.dpToPx(100)));
|
||||
return new PlaceholderViewHolder(v);
|
||||
case MESSAGE_TYPE_HEADER:
|
||||
case MESSAGE_TYPE_FOOTER:
|
||||
return new HeaderFooterViewHolder(CachedInflater.from(parent.getContext()).inflate(R.layout.cursor_adapter_header_footer_view, parent, false));
|
||||
default:
|
||||
throw new IllegalStateException("Cannot create viewholder for type: " + viewType);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||
switch (getItemViewType(position)) {
|
||||
case MESSAGE_TYPE_INCOMING_TEXT:
|
||||
case MESSAGE_TYPE_INCOMING_MULTIMEDIA:
|
||||
case MESSAGE_TYPE_OUTGOING_TEXT:
|
||||
case MESSAGE_TYPE_OUTGOING_MULTIMEDIA:
|
||||
case MESSAGE_TYPE_UPDATE:
|
||||
ConversationViewHolder conversationViewHolder = (ConversationViewHolder) holder;
|
||||
MessageRecord messageRecord = Objects.requireNonNull(getItem(position));
|
||||
int adapterPosition = holder.getAdapterPosition();
|
||||
|
||||
MessageRecord previousRecord = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
|
||||
MessageRecord nextRecord = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
|
||||
|
||||
conversationViewHolder.getView().bind(messageRecord,
|
||||
Optional.fromNullable(previousRecord),
|
||||
Optional.fromNullable(nextRecord),
|
||||
glideRequests,
|
||||
locale,
|
||||
selected,
|
||||
recipient,
|
||||
searchQuery,
|
||||
messageRecord == recordToPulseHighlight);
|
||||
|
||||
if (messageRecord == recordToPulseHighlight) {
|
||||
recordToPulseHighlight = null;
|
||||
}
|
||||
break;
|
||||
case MESSAGE_TYPE_HEADER:
|
||||
((HeaderFooterViewHolder) holder).bind(headerView);
|
||||
break;
|
||||
case MESSAGE_TYPE_FOOTER:
|
||||
((HeaderFooterViewHolder) holder).bind(footerView);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void submitList(@Nullable PagedList<MessageRecord> pagedList) {
|
||||
cleanFastRecords();
|
||||
super.submitList(pagedList);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable MessageRecord getItem(int position) {
|
||||
position = hasHeader() ? position - 1 : position;
|
||||
|
||||
if (position < fastRecords.size()) {
|
||||
return fastRecords.get(position);
|
||||
} else {
|
||||
int correctedPosition = position - fastRecords.size();
|
||||
return super.getItem(correctedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
boolean hasHeader = headerView != null;
|
||||
boolean hasFooter = footerView != null;
|
||||
return super.getItemCount() + fastRecords.size() + (hasHeader ? 1 : 0) + (hasFooter ? 1 : 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
|
||||
if (holder instanceof ConversationViewHolder) {
|
||||
((ConversationViewHolder) holder).getView().unbind();
|
||||
} else if (holder instanceof HeaderFooterViewHolder) {
|
||||
((HeaderFooterViewHolder) holder).unbind();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getHeaderId(int position) {
|
||||
if (isHeaderPosition(position)) return -1;
|
||||
if (isFooterPosition(position)) return -1;
|
||||
if (position >= getItemCount()) return -1;
|
||||
if (position < 0) return -1;
|
||||
|
||||
MessageRecord record = getItem(position);
|
||||
|
||||
if (record == null) return -1;
|
||||
|
||||
calendar.setTime(new Date(record.getDateSent()));
|
||||
return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR));
|
||||
}
|
||||
|
||||
@Override
|
||||
public StickyHeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position) {
|
||||
return new StickyHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_item_header, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindHeaderViewHolder(StickyHeaderViewHolder viewHolder, int position) {
|
||||
MessageRecord messageRecord = Objects.requireNonNull(getItem(position));
|
||||
viewHolder.setText(DateUtils.getRelativeDate(viewHolder.itemView.getContext(), locale, messageRecord.getDateReceived()));
|
||||
}
|
||||
|
||||
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) {
|
||||
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The presence of a header may throw off the position you'd like to jump to. This will return
|
||||
* an adjusted message position based on adapter state.
|
||||
*/
|
||||
@MainThread
|
||||
int getAdapterPositionForMessagePosition(int messagePosition) {
|
||||
return hasHeader() ? messagePosition + 1 : messagePosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the received timestamp for the item at the requested adapter position. Will return 0 if
|
||||
* the position doesn't refer to an incoming message.
|
||||
*/
|
||||
@MainThread
|
||||
long getReceivedTimestamp(int position) {
|
||||
if (isHeaderPosition(position)) return 0;
|
||||
if (isFooterPosition(position)) return 0;
|
||||
if (position >= getItemCount()) return 0;
|
||||
if (position < 0) return 0;
|
||||
|
||||
MessageRecord messageRecord = getItem(position);
|
||||
|
||||
if (messageRecord == null || messageRecord.isOutgoing()) {
|
||||
return 0;
|
||||
} else {
|
||||
return messageRecord.getDateReceived();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the view the appears at the top of the list (because the list is reversed).
|
||||
*/
|
||||
void setFooterView(@Nullable View view) {
|
||||
boolean hadFooter = hasFooter();
|
||||
|
||||
this.footerView = view;
|
||||
|
||||
if (view == null && hadFooter) {
|
||||
notifyItemRemoved(getItemCount());
|
||||
} else if (view != null && hadFooter) {
|
||||
notifyItemChanged(getItemCount() - 1);
|
||||
} else if (view != null) {
|
||||
notifyItemInserted(getItemCount() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the view that appears at the bottom of the list (because the list is reversed).
|
||||
*/
|
||||
void setHeaderView(@Nullable View view) {
|
||||
boolean hadHeader = hasHeader();
|
||||
|
||||
this.headerView = view;
|
||||
|
||||
if (view == null && hadHeader) {
|
||||
notifyItemRemoved(0);
|
||||
} else if (view != null && hadHeader) {
|
||||
notifyItemChanged(0);
|
||||
} else if (view != null) {
|
||||
notifyItemInserted(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the header view, if one was set.
|
||||
*/
|
||||
@Nullable View getHeaderView() {
|
||||
return headerView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Momentarily highlights a row at the requested position.
|
||||
*/
|
||||
void pulseHighlightItem(int position) {
|
||||
if (position >= 0 && position < getItemCount()) {
|
||||
int correctedPosition = isHeaderPosition(position) ? position + 1 : position;
|
||||
|
||||
recordToPulseHighlight = getItem(correctedPosition);
|
||||
notifyItemChanged(correctedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation search query updated. Allows rendering of text highlighting.
|
||||
*/
|
||||
void onSearchQueryUpdated(String query) {
|
||||
this.searchQuery = query;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a record to a memory cache to allow it to be rendered immediately, as opposed to waiting
|
||||
* for a database change.
|
||||
*/
|
||||
@MainThread
|
||||
void addFastRecord(MessageRecord record) {
|
||||
fastRecords.add(0, record);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a record as no-longer-needed. Will be removed from the adapter the next time the database
|
||||
* changes.
|
||||
*/
|
||||
@AnyThread
|
||||
void releaseFastRecord(long id) {
|
||||
synchronized (releasedFastRecords) {
|
||||
releasedFastRecords.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns set of records that are selected in multi-select mode.
|
||||
*/
|
||||
Set<MessageRecord> getSelectedItems() {
|
||||
return new HashSet<>(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all selected records from multi-select mode.
|
||||
*/
|
||||
void clearSelection() {
|
||||
selected.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the selected state of a record in multi-select mode.
|
||||
*/
|
||||
void toggleSelection(MessageRecord record) {
|
||||
if (selected.contains(record)) {
|
||||
selected.remove(record);
|
||||
} else {
|
||||
selected.add(record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provided a pool, this will initialize it with view counts that make sense.
|
||||
*/
|
||||
@MainThread
|
||||
static void initializePool(@NonNull RecyclerView.RecycledViewPool pool) {
|
||||
pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_TEXT, 15);
|
||||
pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_MULTIMEDIA, 15);
|
||||
pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING_TEXT, 15);
|
||||
pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING_MULTIMEDIA, 15);
|
||||
pool.setMaxRecycledViews(MESSAGE_TYPE_PLACEHOLDER, 15);
|
||||
pool.setMaxRecycledViews(MESSAGE_TYPE_HEADER, 1);
|
||||
pool.setMaxRecycledViews(MESSAGE_TYPE_FOOTER, 1);
|
||||
pool.setMaxRecycledViews(MESSAGE_TYPE_UPDATE, 5);
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private void cleanFastRecords() {
|
||||
Util.assertMainThread();
|
||||
|
||||
synchronized (releasedFastRecords) {
|
||||
Iterator<MessageRecord> recordIterator = fastRecords.iterator();
|
||||
while (recordIterator.hasNext()) {
|
||||
long id = recordIterator.next().getId();
|
||||
if (releasedFastRecords.contains(id)) {
|
||||
recordIterator.remove();
|
||||
releasedFastRecords.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasHeader() {
|
||||
return headerView != null;
|
||||
}
|
||||
|
||||
private boolean hasFooter() {
|
||||
return footerView != null;
|
||||
}
|
||||
|
||||
private boolean isHeaderPosition(int position) {
|
||||
return hasHeader() && position == 0;
|
||||
}
|
||||
|
||||
private boolean isFooterPosition(int position) {
|
||||
return hasFooter() && position == (getItemCount() - 1);
|
||||
}
|
||||
|
||||
private static @LayoutRes int getLayoutForViewType(int viewType) {
|
||||
switch (viewType) {
|
||||
case MESSAGE_TYPE_OUTGOING_TEXT: return R.layout.conversation_item_sent_text_only;
|
||||
case MESSAGE_TYPE_OUTGOING_MULTIMEDIA: return R.layout.conversation_item_sent_multimedia;
|
||||
case MESSAGE_TYPE_INCOMING_TEXT: return R.layout.conversation_item_received_text_only;
|
||||
case MESSAGE_TYPE_INCOMING_MULTIMEDIA: return R.layout.conversation_item_received_multimedia;
|
||||
case MESSAGE_TYPE_UPDATE: return R.layout.conversation_item_update;
|
||||
default: throw new IllegalArgumentException("Unknown type!");
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDigest getMessageDigestOrThrow() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA1");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
static class ConversationViewHolder extends RecyclerView.ViewHolder {
|
||||
public <V extends View & BindableConversationItem> ConversationViewHolder(final @NonNull V itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <V extends View & BindableConversationItem> V getView() {
|
||||
//noinspection unchecked
|
||||
return (V)itemView;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static class HeaderViewHolder extends RecyclerView.ViewHolder {
|
||||
static class StickyHeaderViewHolder extends RecyclerView.ViewHolder {
|
||||
TextView textView;
|
||||
|
||||
HeaderViewHolder(View itemView) {
|
||||
StickyHeaderViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
textView = ViewUtil.findById(itemView, R.id.text);
|
||||
}
|
||||
|
||||
HeaderViewHolder(TextView textView) {
|
||||
StickyHeaderViewHolder(TextView textView) {
|
||||
super(textView);
|
||||
this.textView = textView;
|
||||
}
|
||||
@@ -136,351 +539,49 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
|
||||
}
|
||||
}
|
||||
|
||||
private static class HeaderFooterViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private ViewGroup container;
|
||||
|
||||
HeaderFooterViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
this.container = (ViewGroup) itemView;
|
||||
}
|
||||
|
||||
void bind(@Nullable View view) {
|
||||
unbind();
|
||||
|
||||
if (view != null) {
|
||||
container.addView(view);
|
||||
}
|
||||
}
|
||||
|
||||
void unbind() {
|
||||
container.removeAllViews();
|
||||
}
|
||||
}
|
||||
|
||||
private static class PlaceholderViewHolder extends RecyclerView.ViewHolder {
|
||||
PlaceholderViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
}
|
||||
|
||||
private static class DiffCallback extends DiffUtil.ItemCallback<MessageRecord> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull MessageRecord oldItem, @NonNull MessageRecord newItem) {
|
||||
return oldItem.isMms() == newItem.isMms() && oldItem.getId() == newItem.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull MessageRecord oldItem, @NonNull MessageRecord newItem) {
|
||||
// Corner rounding is not part of the model, so we can't use this yet
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ItemClickListener extends BindableConversationItem.EventListener {
|
||||
void onItemClick(MessageRecord item);
|
||||
void onItemLongClick(View maskTarget, MessageRecord item);
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@VisibleForTesting
|
||||
ConversationAdapter(Context context, Cursor cursor) {
|
||||
super(context, cursor);
|
||||
try {
|
||||
this.glideRequests = null;
|
||||
this.locale = null;
|
||||
this.clickListener = null;
|
||||
this.recipient = null;
|
||||
this.inflater = null;
|
||||
this.db = null;
|
||||
this.calendar = null;
|
||||
this.digest = MessageDigest.getInstance("SHA1");
|
||||
} catch (NoSuchAlgorithmException nsae) {
|
||||
throw new AssertionError("SHA1 isn't supported!");
|
||||
}
|
||||
}
|
||||
|
||||
public ConversationAdapter(@NonNull Context context,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@Nullable ItemClickListener clickListener,
|
||||
@Nullable Cursor cursor,
|
||||
@NonNull Recipient recipient)
|
||||
{
|
||||
super(context, cursor);
|
||||
|
||||
try {
|
||||
this.glideRequests = glideRequests;
|
||||
this.locale = locale;
|
||||
this.clickListener = clickListener;
|
||||
this.recipient = recipient;
|
||||
this.inflater = LayoutInflater.from(context);
|
||||
this.db = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
this.calendar = Calendar.getInstance();
|
||||
this.digest = MessageDigest.getInstance("SHA1");
|
||||
|
||||
setHasStableIds(true);
|
||||
} catch (NoSuchAlgorithmException nsae) {
|
||||
throw new AssertionError("SHA1 isn't supported!");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changeCursor(Cursor cursor) {
|
||||
messageRecordCache.clear();
|
||||
super.cleanFastRecords();
|
||||
super.changeCursor(cursor);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onBindItemViewHolder(ViewHolder viewHolder, @NonNull MessageRecord messageRecord) {
|
||||
int adapterPosition = viewHolder.getAdapterPosition();
|
||||
MessageRecord previousRecord = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getRecordForPositionOrThrow(adapterPosition + 1) : null;
|
||||
MessageRecord nextRecord = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getRecordForPositionOrThrow(adapterPosition - 1) : null;
|
||||
|
||||
viewHolder.getView().bind(messageRecord,
|
||||
Optional.fromNullable(previousRecord),
|
||||
Optional.fromNullable(nextRecord),
|
||||
glideRequests,
|
||||
locale,
|
||||
batchSelected,
|
||||
recipient,
|
||||
searchQuery,
|
||||
messageRecord == recordToPulseHighlight);
|
||||
|
||||
if (messageRecord == recordToPulseHighlight) {
|
||||
recordToPulseHighlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
|
||||
long start = System.currentTimeMillis();
|
||||
final V itemView = ViewUtil.inflate(inflater, parent, getLayoutForViewType(viewType));
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (clickListener != null) {
|
||||
clickListener.onItemClick(itemView.getMessageRecord());
|
||||
}
|
||||
});
|
||||
itemView.setOnLongClickListener(view -> {
|
||||
if (clickListener != null) {
|
||||
clickListener.onItemLongClick(itemView, itemView.getMessageRecord());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
itemView.setEventListener(clickListener);
|
||||
Log.d(TAG, "Inflate time: " + (System.currentTimeMillis() - start));
|
||||
return new ViewHolder(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemViewRecycled(ViewHolder holder) {
|
||||
holder.getView().unbind();
|
||||
}
|
||||
|
||||
private @LayoutRes int getLayoutForViewType(int viewType) {
|
||||
switch (viewType) {
|
||||
case MESSAGE_TYPE_AUDIO_OUTGOING:
|
||||
case MESSAGE_TYPE_THUMBNAIL_OUTGOING:
|
||||
case MESSAGE_TYPE_DOCUMENT_OUTGOING:
|
||||
case MESSAGE_TYPE_OUTGOING: return R.layout.conversation_item_sent;
|
||||
case MESSAGE_TYPE_AUDIO_INCOMING:
|
||||
case MESSAGE_TYPE_THUMBNAIL_INCOMING:
|
||||
case MESSAGE_TYPE_DOCUMENT_INCOMING:
|
||||
case MESSAGE_TYPE_INCOMING: return R.layout.conversation_item_received;
|
||||
case MESSAGE_TYPE_UPDATE: return R.layout.conversation_item_update;
|
||||
default: throw new IllegalArgumentException("unsupported item view type given to ConversationAdapter");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(@NonNull MessageRecord messageRecord) {
|
||||
if (messageRecord.isUpdate()) {
|
||||
return MESSAGE_TYPE_UPDATE;
|
||||
} else if (hasAudio(messageRecord)) {
|
||||
if (messageRecord.isOutgoing()) return MESSAGE_TYPE_AUDIO_OUTGOING;
|
||||
else return MESSAGE_TYPE_AUDIO_INCOMING;
|
||||
} else if (hasDocument(messageRecord)) {
|
||||
if (messageRecord.isOutgoing()) return MESSAGE_TYPE_DOCUMENT_OUTGOING;
|
||||
else return MESSAGE_TYPE_DOCUMENT_INCOMING;
|
||||
} else if (hasThumbnail(messageRecord)) {
|
||||
if (messageRecord.isOutgoing()) return MESSAGE_TYPE_THUMBNAIL_OUTGOING;
|
||||
else return MESSAGE_TYPE_THUMBNAIL_INCOMING;
|
||||
} else if (messageRecord.isOutgoing()) {
|
||||
return MESSAGE_TYPE_OUTGOING;
|
||||
} else {
|
||||
return MESSAGE_TYPE_INCOMING;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isRecordForId(@NonNull MessageRecord record, long id) {
|
||||
return record.getId() == id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(@NonNull Cursor cursor) {
|
||||
List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(getContext()).getAttachment(cursor);
|
||||
List<DatabaseAttachment> messageAttachments = Stream.of(attachments).filterNot(DatabaseAttachment::isQuote).toList();
|
||||
|
||||
if (messageAttachments.size() > 0 && messageAttachments.get(0).getFastPreflightId() != null) {
|
||||
return Long.valueOf(messageAttachments.get(0).getFastPreflightId());
|
||||
}
|
||||
|
||||
final String unique = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsColumns.UNIQUE_ROW_ID));
|
||||
final byte[] bytes = digest.digest(unique.getBytes());
|
||||
return Conversions.byteArrayToLong(bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected long getItemId(@NonNull MessageRecord record) {
|
||||
if (record.isOutgoing() && record.isMms()) {
|
||||
MmsMessageRecord mmsRecord = (MmsMessageRecord) record;
|
||||
SlideDeck slideDeck = mmsRecord.getSlideDeck();
|
||||
|
||||
if (slideDeck.getThumbnailSlide() != null && slideDeck.getThumbnailSlide().getFastPreflightId() != null) {
|
||||
return Long.valueOf(slideDeck.getThumbnailSlide().getFastPreflightId());
|
||||
}
|
||||
|
||||
if (slideDeck.getStickerSlide() != null && slideDeck.getStickerSlide().getFastPreflightId() != null) {
|
||||
return Long.valueOf(slideDeck.getStickerSlide().getFastPreflightId());
|
||||
}
|
||||
}
|
||||
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MessageRecord getRecordFromCursor(@NonNull Cursor cursor) {
|
||||
long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID));
|
||||
String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT));
|
||||
|
||||
final SoftReference<MessageRecord> reference = messageRecordCache.get(type + messageId);
|
||||
if (reference != null) {
|
||||
final MessageRecord record = reference.get();
|
||||
if (record != null) return record;
|
||||
}
|
||||
|
||||
final MessageRecord messageRecord = db.readerFor(cursor).getCurrent();
|
||||
messageRecordCache.put(type + messageId, new SoftReference<>(messageRecord));
|
||||
|
||||
return messageRecord;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
getCursor().close();
|
||||
}
|
||||
|
||||
public int findLastSeenPosition(long lastSeen) {
|
||||
if (lastSeen <= 0) return -1;
|
||||
if (!isActiveCursor()) return -1;
|
||||
|
||||
int count = getItemCount() - (hasFooterView() ? 1 : 0);
|
||||
|
||||
for (int i=(hasHeaderView() ? 1 : 0);i<count;i++) {
|
||||
MessageRecord messageRecord = getRecordForPositionOrThrow(i);
|
||||
|
||||
if (messageRecord.isOutgoing() || messageRecord.getDateReceived() <= lastSeen) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public void toggleSelection(MessageRecord messageRecord) {
|
||||
if (!batchSelected.remove(messageRecord)) {
|
||||
batchSelected.add(messageRecord);
|
||||
}
|
||||
}
|
||||
|
||||
public void clearSelection() {
|
||||
batchSelected.clear();
|
||||
}
|
||||
|
||||
public Set<MessageRecord> getSelectedItems() {
|
||||
return Collections.unmodifiableSet(new HashSet<>(batchSelected));
|
||||
}
|
||||
|
||||
public void pulseHighlightItem(int position) {
|
||||
if (position < getItemCount()) {
|
||||
recordToPulseHighlight = getRecordForPositionOrThrow(position);
|
||||
notifyItemChanged(position);
|
||||
}
|
||||
}
|
||||
|
||||
public void onSearchQueryUpdated(@Nullable String query) {
|
||||
this.searchQuery = query;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
private boolean hasAudio(MessageRecord messageRecord) {
|
||||
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null;
|
||||
}
|
||||
|
||||
private boolean hasDocument(MessageRecord messageRecord) {
|
||||
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getDocumentSlide() != null;
|
||||
}
|
||||
|
||||
private boolean hasThumbnail(MessageRecord messageRecord) {
|
||||
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide() != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getHeaderId(int position) {
|
||||
if (!isActiveCursor()) return -1;
|
||||
if (isHeaderPosition(position)) return -1;
|
||||
if (isFooterPosition(position)) return -1;
|
||||
if (position >= getItemCount()) return -1;
|
||||
if (position < 0) return -1;
|
||||
|
||||
MessageRecord record = getRecordForPositionOrThrow(position);
|
||||
|
||||
calendar.setTime(new Date(record.getDateSent()));
|
||||
return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR));
|
||||
}
|
||||
|
||||
public long getReceivedTimestamp(int position) {
|
||||
if (!isActiveCursor()) return 0;
|
||||
if (isHeaderPosition(position)) return 0;
|
||||
if (isFooterPosition(position)) return 0;
|
||||
if (position >= getItemCount()) return 0;
|
||||
if (position < 0) return 0;
|
||||
|
||||
MessageRecord messageRecord = getRecordForPositionOrThrow(position);
|
||||
|
||||
if (messageRecord.isOutgoing()) return 0;
|
||||
else return messageRecord.getDateReceived();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position) {
|
||||
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.conversation_item_header, parent, false));
|
||||
}
|
||||
|
||||
public HeaderViewHolder onCreateLastSeenViewHolder(ViewGroup parent) {
|
||||
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.conversation_item_last_seen, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) {
|
||||
MessageRecord messageRecord = getRecordForPositionOrThrow(position);
|
||||
viewHolder.setText(DateUtils.getRelativeDate(getContext(), locale, messageRecord.getDateReceived()));
|
||||
}
|
||||
|
||||
public void onBindLastSeenViewHolder(HeaderViewHolder viewHolder, int position) {
|
||||
viewHolder.setText(getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1)));
|
||||
}
|
||||
|
||||
static class LastSeenHeader extends StickyHeaderDecoration {
|
||||
|
||||
private final ConversationAdapter adapter;
|
||||
private final long lastSeenTimestamp;
|
||||
|
||||
LastSeenHeader(ConversationAdapter adapter, long lastSeenTimestamp) {
|
||||
super(adapter, false, false);
|
||||
this.adapter = adapter;
|
||||
this.lastSeenTimestamp = lastSeenTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
|
||||
if (!adapter.isActiveCursor()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lastSeenTimestamp <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long currentRecordTimestamp = adapter.getReceivedTimestamp(position);
|
||||
long previousRecordTimestamp = adapter.getReceivedTimestamp(position + 1);
|
||||
|
||||
return currentRecordTimestamp > lastSeenTimestamp && previousRecordTimestamp < lastSeenTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, int layoutPos) {
|
||||
return parent.getLayoutManager().getDecoratedTop(child);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HeaderViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
|
||||
HeaderViewHolder viewHolder = adapter.onCreateLastSeenViewHolder(parent);
|
||||
adapter.onBindLastSeenViewHolder(viewHolder, position);
|
||||
|
||||
int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
|
||||
int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
|
||||
|
||||
int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.getPaddingLeft() + parent.getPaddingRight(), viewHolder.itemView.getLayoutParams().width);
|
||||
int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.getPaddingTop() + parent.getPaddingBottom(), viewHolder.itemView.getLayoutParams().height);
|
||||
|
||||
viewHolder.itemView.measure(childWidth, childHeight);
|
||||
viewHolder.itemView.layout(0, 0, viewHolder.itemView.getMeasuredWidth(), viewHolder.itemView.getMeasuredHeight());
|
||||
|
||||
return viewHolder;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
/**
|
||||
* Represents metadata about a conversation.
|
||||
*/
|
||||
final class ConversationData {
|
||||
private final long threadId;
|
||||
private final long lastSeen;
|
||||
private final int lastSeenPosition;
|
||||
private final boolean hasSent;
|
||||
private final boolean isMessageRequestAccepted;
|
||||
private final boolean hasPreMessageRequestMessages;
|
||||
private final int jumpToPosition;
|
||||
|
||||
ConversationData(long threadId,
|
||||
long lastSeen,
|
||||
int lastSeenPosition,
|
||||
boolean hasSent,
|
||||
boolean isMessageRequestAccepted,
|
||||
boolean hasPreMessageRequestMessages,
|
||||
int jumpToPosition)
|
||||
{
|
||||
this.threadId = threadId;
|
||||
this.lastSeen = lastSeen;
|
||||
this.lastSeenPosition = lastSeenPosition;
|
||||
this.hasSent = hasSent;
|
||||
this.isMessageRequestAccepted = isMessageRequestAccepted;
|
||||
this.hasPreMessageRequestMessages = hasPreMessageRequestMessages;
|
||||
this.jumpToPosition = jumpToPosition;
|
||||
}
|
||||
|
||||
public long getThreadId() {
|
||||
return threadId;
|
||||
}
|
||||
|
||||
long getLastSeen() {
|
||||
return lastSeen;
|
||||
}
|
||||
|
||||
int getLastSeenPosition() {
|
||||
return lastSeenPosition;
|
||||
}
|
||||
|
||||
boolean hasSent() {
|
||||
return hasSent;
|
||||
}
|
||||
|
||||
boolean isMessageRequestAccepted() {
|
||||
return isMessageRequestAccepted;
|
||||
}
|
||||
|
||||
boolean hasPreMessageRequestMessages() {
|
||||
return hasPreMessageRequestMessages;
|
||||
}
|
||||
|
||||
boolean shouldJumpToMessage() {
|
||||
return jumpToPosition >= 0;
|
||||
}
|
||||
|
||||
int getJumpToPosition() {
|
||||
return jumpToPosition;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.ContentObserver;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.paging.DataSource;
|
||||
import androidx.paging.PositionalDataSource;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* Core data source for loading an individual conversation.
|
||||
*/
|
||||
class ConversationDataSource extends PositionalDataSource<MessageRecord> {
|
||||
|
||||
private static final String TAG = Log.tag(ConversationDataSource.class);
|
||||
|
||||
public static final Executor EXECUTOR = SignalExecutors.newFixedLifoThreadExecutor("signal-conversation", 1, 1);
|
||||
|
||||
private final Context context;
|
||||
private final long threadId;
|
||||
private final DataUpdatedCallback dataUpdateCallback;
|
||||
|
||||
private ConversationDataSource(@NonNull Context context,
|
||||
long threadId,
|
||||
@NonNull Invalidator invalidator,
|
||||
@NonNull DataUpdatedCallback dataUpdateCallback)
|
||||
{
|
||||
this.context = context;
|
||||
this.threadId = threadId;
|
||||
this.dataUpdateCallback = dataUpdateCallback;
|
||||
|
||||
ContentObserver contentObserver = new ContentObserver(null) {
|
||||
@Override
|
||||
public void onChange(boolean selfChange) {
|
||||
invalidate();
|
||||
context.getContentResolver().unregisterContentObserver(this);
|
||||
}
|
||||
};
|
||||
|
||||
invalidator.observe(() -> {
|
||||
invalidate();
|
||||
context.getContentResolver().unregisterContentObserver(contentObserver);
|
||||
});
|
||||
|
||||
context.getContentResolver().registerContentObserver(DatabaseContentProviders.Conversation.getUriForThread(threadId), true, contentObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<MessageRecord> callback) {
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
List<MessageRecord> records = new ArrayList<>(params.requestedLoadSize);
|
||||
int totalCount = db.getConversationCount(threadId);
|
||||
int effectiveCount = params.requestedStartPosition;
|
||||
|
||||
if (totalCount == 0 || params.requestedStartPosition > totalCount) {
|
||||
|
||||
}
|
||||
|
||||
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.requestedStartPosition, params.requestedLoadSize))) {
|
||||
MessageRecord record;
|
||||
while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) {
|
||||
records.add(record);
|
||||
effectiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInvalid()) {
|
||||
SizeFixResult result = ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount);
|
||||
|
||||
callback.onResult(result.messages, params.requestedStartPosition, result.total);
|
||||
Util.runOnMain(dataUpdateCallback::onDataUpdated);
|
||||
}
|
||||
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : ""));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<MessageRecord> callback) {
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
List<MessageRecord> records = new ArrayList<>(params.loadSize);
|
||||
|
||||
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.startPosition, params.loadSize))) {
|
||||
MessageRecord record;
|
||||
while ((record = reader.getNext()) != null && !isInvalid()) {
|
||||
records.add(record);
|
||||
}
|
||||
}
|
||||
|
||||
callback.onResult(records);
|
||||
|
||||
if (!isInvalid()) {
|
||||
Util.runOnMain(dataUpdateCallback::onDataUpdated);
|
||||
}
|
||||
|
||||
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : ""));
|
||||
}
|
||||
|
||||
private static @NonNull SizeFixResult ensureMultipleOfPageSize(@NonNull List<MessageRecord> records,
|
||||
int startPosition,
|
||||
int pageSize,
|
||||
int total)
|
||||
{
|
||||
if (records.size() + startPosition == total || (records.size() != 0 && records.size() % pageSize == 0)) {
|
||||
return new SizeFixResult(records, total);
|
||||
}
|
||||
|
||||
if (records.size() < pageSize) {
|
||||
Log.w(TAG, "Hit a miscalculation where we don't have the full dataset, but it's smaller than a page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total);
|
||||
return new SizeFixResult(records, records.size() + startPosition);
|
||||
}
|
||||
|
||||
Log.w(TAG, "Hit a miscalculation where our data size isn't a multiple of the page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total);
|
||||
int overflow = records.size() % pageSize;
|
||||
|
||||
return new SizeFixResult(records.subList(0, records.size() - overflow), total);
|
||||
}
|
||||
|
||||
private static class SizeFixResult {
|
||||
final List<MessageRecord> messages;
|
||||
final int total;
|
||||
|
||||
private SizeFixResult(@NonNull List<MessageRecord> messages, int total) {
|
||||
this.messages = messages;
|
||||
this.total = total;
|
||||
}
|
||||
}
|
||||
|
||||
interface DataUpdatedCallback {
|
||||
void onDataUpdated();
|
||||
}
|
||||
|
||||
static class Invalidator {
|
||||
private Runnable callback;
|
||||
|
||||
synchronized void invalidate() {
|
||||
if (callback != null) {
|
||||
callback.run();
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void observe(@NonNull Runnable callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
static class Factory extends DataSource.Factory<Integer, MessageRecord> {
|
||||
|
||||
private final Context context;
|
||||
private final long threadId;
|
||||
private final Invalidator invalidator;
|
||||
private final DataUpdatedCallback callback;
|
||||
|
||||
Factory(Context context, long threadId, @NonNull Invalidator invalidator, @NonNull DataUpdatedCallback callback) {
|
||||
this.context = context;
|
||||
this.threadId = threadId;
|
||||
this.invalidator = invalidator;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull DataSource<Integer, MessageRecord> create() {
|
||||
return new ConversationDataSource(context, threadId, invalidator, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Rect;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
@@ -37,6 +37,7 @@ import android.view.ViewGroup;
|
||||
import android.view.Window;
|
||||
import android.view.animation.Animation;
|
||||
import android.view.animation.AnimationUtils;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import android.widget.ViewSwitcher;
|
||||
@@ -52,8 +53,6 @@ import androidx.core.app.ActivityOptionsCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
|
||||
@@ -65,7 +64,6 @@ import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.MessageDetailsActivity;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.sharing.ShareActivity;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
@@ -73,17 +71,17 @@ import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearL
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.HeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.loaders.ConversationLoader;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
@@ -101,12 +99,15 @@ import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity;
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
|
||||
import org.thoughtcrime.securesms.sharing.ShareActivity;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.HtmlUtil;
|
||||
@@ -133,13 +134,9 @@ import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
public class ConversationFragment extends Fragment
|
||||
implements LoaderManager.LoaderCallbacks<Cursor>
|
||||
{
|
||||
private static final String TAG = ConversationFragment.class.getSimpleName();
|
||||
private static final String KEY_LIMIT = "limit";
|
||||
public class ConversationFragment extends Fragment {
|
||||
private static final String TAG = ConversationFragment.class.getSimpleName();
|
||||
|
||||
private static final int PARTIAL_CONVERSATION_LIMIT = 500;
|
||||
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
|
||||
private static final int CODE_ADD_EDIT_CONTACT = 77;
|
||||
|
||||
@@ -150,11 +147,6 @@ public class ConversationFragment extends Fragment
|
||||
|
||||
private LiveRecipient recipient;
|
||||
private long threadId;
|
||||
private long lastSeen;
|
||||
private int startingPosition;
|
||||
private int previousOffset;
|
||||
private int activeOffset;
|
||||
private boolean firstLoad;
|
||||
private boolean isReacting;
|
||||
private ActionMode actionMode;
|
||||
private Locale locale;
|
||||
@@ -170,6 +162,19 @@ public class ConversationFragment extends Fragment
|
||||
private ConversationBannerView conversationBanner;
|
||||
private ConversationBannerView emptyConversationBanner;
|
||||
private MessageRequestViewModel messageRequestViewModel;
|
||||
private ConversationViewModel conversationViewModel;
|
||||
|
||||
private Deferred deferred = new Deferred();
|
||||
|
||||
public static void prepare(@NonNull Context context) {
|
||||
FrameLayout parent = new FrameLayout(context);
|
||||
parent.setLayoutParams(new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_received_multimedia, parent, 10);
|
||||
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_sent_multimedia, parent, 10);
|
||||
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_update, parent, 5);
|
||||
CachedInflater.from(context).cacheUntilLimit(R.layout.cursor_adapter_header_footer_view, parent, 2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle icicle) {
|
||||
@@ -212,6 +217,17 @@ public class ConversationFragment extends Fragment
|
||||
|
||||
setupListLayoutListeners();
|
||||
|
||||
this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
|
||||
conversationViewModel.getMessages().observe(this, list -> {
|
||||
if (getListAdapter() != null && !list.getDataSource().isInvalid()) {
|
||||
Log.i(TAG, "submitList");
|
||||
getListAdapter().submitList(list);
|
||||
} else if (list.getDataSource().isInvalid()) {
|
||||
Log.i(TAG, "submitList skipped an invalid list");
|
||||
}
|
||||
});
|
||||
conversationViewModel.getConversationMetadata().observe(this, data -> deferred.defer(() -> presentConversationMetadata(data)));
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -268,15 +284,6 @@ public class ConversationFragment extends Fragment
|
||||
initializeTypingObserver();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
if (list.getAdapter() != null) {
|
||||
list.getAdapter().notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
super.onStop();
|
||||
@@ -291,18 +298,10 @@ public class ConversationFragment extends Fragment
|
||||
initializeResources();
|
||||
messageRequestViewModel.setConversationInfo(recipient.getId(), threadId);
|
||||
initializeListAdapter();
|
||||
|
||||
if (threadId == -1) {
|
||||
getLoaderManager().restartLoader(0, Bundle.EMPTY, this);
|
||||
}
|
||||
}
|
||||
|
||||
public void reloadList() {
|
||||
getLoaderManager().restartLoader(0, Bundle.EMPTY, this);
|
||||
}
|
||||
|
||||
public void moveToLastSeen() {
|
||||
if (lastSeen <= 0) {
|
||||
if (conversationViewModel.getLastSeenPosition() <= 0) {
|
||||
Log.i(TAG, "No need to move to last seen.");
|
||||
return;
|
||||
}
|
||||
@@ -312,7 +311,7 @@ public class ConversationFragment extends Fragment
|
||||
return;
|
||||
}
|
||||
|
||||
int position = getListAdapter().findLastSeenPosition(lastSeen);
|
||||
int position = getListAdapter().getAdapterPositionForMessagePosition(conversationViewModel.getLastSeenPosition());
|
||||
scrollToLastSeenPosition(position);
|
||||
}
|
||||
|
||||
@@ -401,12 +400,14 @@ public class ConversationFragment extends Fragment
|
||||
private void initializeResources() {
|
||||
long oldThreadId = threadId;
|
||||
|
||||
int startingPosition = this.getActivity().getIntent().getIntExtra(ConversationActivity.STARTING_POSITION_EXTRA, -1);
|
||||
|
||||
this.recipient = Recipient.live(getActivity().getIntent().getParcelableExtra(ConversationActivity.RECIPIENT_EXTRA));
|
||||
this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1);
|
||||
this.lastSeen = this.getActivity().getIntent().getLongExtra(ConversationActivity.LAST_SEEN_EXTRA, -1);
|
||||
this.startingPosition = this.getActivity().getIntent().getIntExtra(ConversationActivity.STARTING_POSITION_EXTRA, -1);
|
||||
this.firstLoad = true;
|
||||
this.unknownSenderView = new UnknownSenderView(getActivity(), recipient.get(), threadId);
|
||||
this.unknownSenderView = new UnknownSenderView(getActivity(), recipient.get(), threadId, () -> clearHeaderIfNotTyping(getListAdapter()));
|
||||
|
||||
deferred.setDeferred(true);
|
||||
conversationViewModel.onConversationDataAvailable(threadId, startingPosition);
|
||||
|
||||
OnScrollListener scrollListener = new ConversationScrollListener(getActivity());
|
||||
list.addOnScrollListener(scrollListener);
|
||||
@@ -419,12 +420,14 @@ public class ConversationFragment extends Fragment
|
||||
private void initializeListAdapter() {
|
||||
if (this.recipient != null && this.threadId != -1) {
|
||||
Log.d(TAG, "Initializing adapter for " + recipient.getId());
|
||||
ConversationAdapter adapter = new ConversationAdapter(requireContext(), GlideApp.with(this), locale, selectionClickListener, null, this.recipient.get());
|
||||
ConversationAdapter adapter = new ConversationAdapter(GlideApp.with(this), locale, selectionClickListener, this.recipient.get());
|
||||
list.setAdapter(adapter);
|
||||
list.addItemDecoration(new StickyHeaderDecoration(adapter, false, false));
|
||||
ConversationAdapter.initializePool(list.getRecycledViewPool());
|
||||
|
||||
setLastSeen(lastSeen);
|
||||
getLoaderManager().restartLoader(0, Bundle.EMPTY, this);
|
||||
adapter.registerAdapterDataObserver(new DataObserver());
|
||||
|
||||
setLastSeen(conversationViewModel.getLastSeen());
|
||||
|
||||
emptyConversationBanner.setVisibility(View.GONE);
|
||||
} else if (FeatureFlags.messageRequests() && threadId == -1) {
|
||||
@@ -434,9 +437,6 @@ public class ConversationFragment extends Fragment
|
||||
|
||||
private void initializeLoadMoreView(ViewSwitcher loadMoreView) {
|
||||
loadMoreView.setOnClickListener(v -> {
|
||||
Bundle args = new Bundle();
|
||||
args.putInt(KEY_LIMIT, 0);
|
||||
getLoaderManager().restartLoader(0, args, ConversationFragment.this);
|
||||
loadMoreView.showNext();
|
||||
loadMoreView.setOnClickListener(null);
|
||||
});
|
||||
@@ -480,14 +480,11 @@ public class ConversationFragment extends Fragment
|
||||
});
|
||||
list.postDelayed(() -> list.setVerticalScrollBarEnabled(true), 300);
|
||||
adapter.setHeaderView(typingView);
|
||||
adapter.notifyItemInserted(0);
|
||||
} else {
|
||||
if (isTypingIndicatorShowing()) {
|
||||
adapter.setHeaderView(typingView);
|
||||
adapter.notifyItemChanged(0);
|
||||
} else {
|
||||
adapter.setHeaderView(typingView);
|
||||
adapter.notifyItemInserted(0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -498,12 +495,10 @@ public class ConversationFragment extends Fragment
|
||||
list.setVerticalScrollBarEnabled(false);
|
||||
list.postDelayed(() -> {
|
||||
adapter.setHeaderView(null);
|
||||
adapter.notifyItemRemoved(0);
|
||||
list.post(() -> list.setVerticalScrollBarEnabled(true));
|
||||
}, 200);
|
||||
} else if (!replacedByIncomingMessage) {
|
||||
adapter.setHeaderView(null);
|
||||
adapter.notifyItemRemoved(0);
|
||||
} else {
|
||||
adapter.setHeaderView(null);
|
||||
}
|
||||
@@ -550,6 +545,9 @@ public class ConversationFragment extends Fragment
|
||||
if (this.threadId != threadId) {
|
||||
this.threadId = threadId;
|
||||
messageRequestViewModel.setConversationInfo(recipient.getId(), threadId);
|
||||
|
||||
deferred.setDeferred(true);
|
||||
conversationViewModel.onConversationDataAvailable(threadId, -1);
|
||||
initializeListAdapter();
|
||||
}
|
||||
}
|
||||
@@ -563,12 +561,11 @@ public class ConversationFragment extends Fragment
|
||||
}
|
||||
|
||||
public void setLastSeen(long lastSeen) {
|
||||
this.lastSeen = lastSeen;
|
||||
if (lastSeenDecoration != null) {
|
||||
list.removeItemDecoration(lastSeenDecoration);
|
||||
}
|
||||
|
||||
lastSeenDecoration = new ConversationAdapter.LastSeenHeader(getListAdapter(), lastSeen);
|
||||
lastSeenDecoration = new LastSeenHeader(getListAdapter(), lastSeen);
|
||||
list.addItemDecoration(lastSeenDecoration);
|
||||
}
|
||||
|
||||
@@ -825,109 +822,12 @@ public class ConversationFragment extends Fragment
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
Log.i(TAG, "onCreateLoader");
|
||||
|
||||
int limit = args.getInt(KEY_LIMIT, PARTIAL_CONVERSATION_LIMIT);
|
||||
int offset = 0;
|
||||
if (limit != 0 && startingPosition >= limit) {
|
||||
offset = Math.max(startingPosition - (limit / 2) + 1, 0);
|
||||
startingPosition -= offset - 1;
|
||||
}
|
||||
|
||||
return new ConversationLoader(getActivity(), threadId, offset, limit, lastSeen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<Cursor> cursorLoader, Cursor cursor) {
|
||||
int count = cursor.getCount();
|
||||
ConversationLoader loader = (ConversationLoader) cursorLoader;
|
||||
|
||||
ConversationAdapter adapter = getListAdapter();
|
||||
if (adapter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursor.getCount() >= PARTIAL_CONVERSATION_LIMIT && loader.hasLimit()) {
|
||||
adapter.setFooterView(topLoadMoreView);
|
||||
} else if (FeatureFlags.messageRequests()) {
|
||||
adapter.setFooterView(conversationBanner);
|
||||
} else {
|
||||
adapter.setFooterView(null);
|
||||
}
|
||||
|
||||
if (lastSeen == -1) {
|
||||
setLastSeen(loader.getLastSeen());
|
||||
}
|
||||
|
||||
if (FeatureFlags.messageRequests() && !loader.hasPreMessageRequestMessages()) {
|
||||
clearHeaderIfNotTyping(adapter);
|
||||
} else {
|
||||
if (!loader.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isGroup() && recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
adapter.setHeaderView(unknownSenderView);
|
||||
} else {
|
||||
clearHeaderIfNotTyping(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
if (loader.hasOffset()) {
|
||||
adapter.setHeaderView(bottomLoadMoreView);
|
||||
}
|
||||
|
||||
if (firstLoad || loader.hasOffset()) {
|
||||
previousOffset = loader.getOffset();
|
||||
}
|
||||
|
||||
activeOffset = loader.getOffset();
|
||||
adapter.changeCursor(cursor);
|
||||
listener.onCursorChanged();
|
||||
|
||||
int lastSeenPosition = adapter.findLastSeenPosition(lastSeen);
|
||||
|
||||
if (isTypingIndicatorShowing()) {
|
||||
lastSeenPosition = Math.max(lastSeenPosition - 1, 0);
|
||||
}
|
||||
|
||||
if (firstLoad) {
|
||||
if (startingPosition >= 0) {
|
||||
scrollToStartingPosition(startingPosition);
|
||||
} else if (loader.isMessageRequestAccepted()) {
|
||||
scrollToLastSeenPosition(lastSeenPosition);
|
||||
} else if (FeatureFlags.messageRequests()) {
|
||||
list.post(() -> getListLayoutManager().scrollToPosition(adapter.getItemCount() - 1));
|
||||
}
|
||||
firstLoad = false;
|
||||
} else if (previousOffset > 0) {
|
||||
int scrollPosition = previousOffset + getListLayoutManager().findFirstVisibleItemPosition();
|
||||
scrollPosition = Math.min(scrollPosition, count - 1);
|
||||
|
||||
View firstView = list.getLayoutManager().getChildAt(scrollPosition);
|
||||
int pixelOffset = (firstView == null) ? 0 : (firstView.getBottom() - list.getPaddingBottom());
|
||||
|
||||
getListLayoutManager().scrollToPositionWithOffset(scrollPosition, pixelOffset);
|
||||
previousOffset = 0;
|
||||
}
|
||||
|
||||
if (lastSeenPosition <= 0) {
|
||||
setLastSeen(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearHeaderIfNotTyping(ConversationAdapter adapter) {
|
||||
if (adapter.getHeaderView() != typingView) {
|
||||
adapter.setHeaderView(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<Cursor> arg0) {
|
||||
if (list.getAdapter() != null) {
|
||||
getListAdapter().changeCursor(null);
|
||||
listener.onCursorChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public long stageOutgoingMessage(OutgoingMediaMessage message) {
|
||||
MessageRecord messageRecord = DatabaseFactory.getMmsDatabase(getContext()).readerFor(message, threadId).getCurrent();
|
||||
|
||||
@@ -935,6 +835,7 @@ public class ConversationFragment extends Fragment
|
||||
clearHeaderIfNotTyping(getListAdapter());
|
||||
setLastSeen(0);
|
||||
getListAdapter().addFastRecord(messageRecord);
|
||||
list.post(() -> list.scrollToPosition(0));
|
||||
}
|
||||
|
||||
return messageRecord.getId();
|
||||
@@ -947,6 +848,7 @@ public class ConversationFragment extends Fragment
|
||||
clearHeaderIfNotTyping(getListAdapter());
|
||||
setLastSeen(0);
|
||||
getListAdapter().addFastRecord(messageRecord);
|
||||
list.post(() -> list.scrollToPosition(0));
|
||||
}
|
||||
|
||||
return messageRecord.getId();
|
||||
@@ -958,14 +860,51 @@ public class ConversationFragment extends Fragment
|
||||
}
|
||||
}
|
||||
|
||||
private void scrollToStartingPosition(final int startingPosition) {
|
||||
private void presentConversationMetadata(@NonNull ConversationData conversation) {
|
||||
ConversationAdapter adapter = getListAdapter();
|
||||
if (adapter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (FeatureFlags.messageRequests()) {
|
||||
adapter.setFooterView(conversationBanner);
|
||||
} else {
|
||||
adapter.setFooterView(null);
|
||||
}
|
||||
|
||||
setLastSeen(conversation.getLastSeen());
|
||||
|
||||
if (FeatureFlags.messageRequests() && !conversation.hasPreMessageRequestMessages()) {
|
||||
clearHeaderIfNotTyping(adapter);
|
||||
} else {
|
||||
if (!conversation.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isGroup() && recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
adapter.setHeaderView(unknownSenderView);
|
||||
} else {
|
||||
clearHeaderIfNotTyping(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
listener.onCursorChanged();
|
||||
|
||||
int lastSeenPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastSeenPosition());
|
||||
|
||||
if (conversation.shouldJumpToMessage()) {
|
||||
scrollToStartingPosition(conversation.getJumpToPosition());
|
||||
} else if (conversation.isMessageRequestAccepted()) {
|
||||
scrollToLastSeenPosition(lastSeenPosition);
|
||||
} else if (FeatureFlags.messageRequests()) {
|
||||
list.post(() -> getListLayoutManager().scrollToPosition(adapter.getItemCount() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
private void scrollToStartingPosition(int startingPosition) {
|
||||
list.post(() -> {
|
||||
list.getLayoutManager().scrollToPosition(startingPosition);
|
||||
getListAdapter().pulseHighlightItem(startingPosition);
|
||||
});
|
||||
}
|
||||
|
||||
private void scrollToLastSeenPosition(final int lastSeenPosition) {
|
||||
private void scrollToLastSeenPosition(int lastSeenPosition) {
|
||||
if (lastSeenPosition > 0) {
|
||||
list.post(() -> getListLayoutManager().scrollToPositionWithOffset(lastSeenPosition, list.getHeight()));
|
||||
}
|
||||
@@ -1002,23 +941,23 @@ public class ConversationFragment extends Fragment
|
||||
}
|
||||
|
||||
private void moveToMessagePosition(int position, @Nullable Runnable onMessageNotFound) {
|
||||
Log.d(TAG, "Moving to message position: " + position + " activeOffset: " + activeOffset + " cursorCount: " + getListAdapter().getCursorCount());
|
||||
if (position >= 0) {
|
||||
list.scrollToPosition(position);
|
||||
|
||||
if (position >= activeOffset && position >= 0 && position < getListAdapter().getCursorCount()) {
|
||||
int offset = activeOffset > 0 ? activeOffset - 1 : 0;
|
||||
list.scrollToPosition(position - offset);
|
||||
getListAdapter().pulseHighlightItem(position - offset);
|
||||
} else if (position < 0) {
|
||||
Log.w(TAG, "Tried to navigate to message, but it wasn't found.");
|
||||
if (getListAdapter() == null || getListAdapter().getItem(position) == null) {
|
||||
Log.i(TAG, "[moveToMessagePosition] Position " + position + " not currently populated. Scheduling a jump.");
|
||||
conversationViewModel.scheduleForNextMessageUpdate(() -> {
|
||||
list.scrollToPosition(position);
|
||||
getListAdapter().pulseHighlightItem(position);
|
||||
});
|
||||
} else {
|
||||
getListAdapter().pulseHighlightItem(position);
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "[moveToMessagePosition] Tried to navigate to message, but it wasn't found.");
|
||||
if (onMessageNotFound != null) {
|
||||
onMessageNotFound.run();
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Message was outside of the loaded range. Need to restart the loader.");
|
||||
|
||||
firstLoad = true;
|
||||
startingPosition = position;
|
||||
getLoaderManager().restartLoader(0, Bundle.EMPTY, ConversationFragment.this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1112,13 +1051,51 @@ public class ConversationFragment extends Fragment
|
||||
return getListLayoutManager().findLastVisibleItemPosition();
|
||||
}
|
||||
|
||||
private void bindScrollHeader(HeaderViewHolder headerViewHolder, int positionId) {
|
||||
private void bindScrollHeader(StickyHeaderViewHolder headerViewHolder, int positionId) {
|
||||
if (((ConversationAdapter)list.getAdapter()).getHeaderId(positionId) != -1) {
|
||||
((ConversationAdapter) list.getAdapter()).onBindHeaderViewHolder(headerViewHolder, positionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DataObserver extends RecyclerView.AdapterDataObserver {
|
||||
|
||||
private final Rect rect = new Rect();
|
||||
|
||||
@Override
|
||||
public void onItemRangeInserted(int positionStart, int itemCount) {
|
||||
if (deferred.isDeferred()) {
|
||||
deferred.setDeferred(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (positionStart == 0 && itemCount == 1 && isTypingIndicatorShowing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (list.getScrollState() == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
int firstVisibleItem = getListLayoutManager().findFirstVisibleItemPosition();
|
||||
|
||||
if (firstVisibleItem == 0) {
|
||||
View view = getListLayoutManager().findViewByPosition(0);
|
||||
if (view == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.getDrawingRect(rect);
|
||||
list.offsetDescendantRectToMyCoords(view, rect);
|
||||
|
||||
int bottom = rect.bottom;
|
||||
list.getDrawingRect(rect);
|
||||
|
||||
if (bottom <= rect.bottom) {
|
||||
getListLayoutManager().scrollToPosition(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ConversationFragmentItemClickListener implements ItemClickListener {
|
||||
|
||||
@Override
|
||||
@@ -1301,6 +1278,13 @@ public class ConversationFragment extends Fragment
|
||||
|
||||
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) {
|
||||
if (getContext() == null) return;
|
||||
|
||||
RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(requireFragmentManager(), "BOTTOM");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1422,7 +1406,7 @@ public class ConversationFragment extends Fragment
|
||||
}
|
||||
}
|
||||
|
||||
private static class ConversationDateHeader extends HeaderViewHolder {
|
||||
private static class ConversationDateHeader extends StickyHeaderViewHolder {
|
||||
|
||||
private final Animation animateIn;
|
||||
private final Animation animateOut;
|
||||
@@ -1464,4 +1448,34 @@ public class ConversationFragment extends Fragment
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
private static class Deferred {
|
||||
|
||||
private Runnable deferred;
|
||||
private boolean isDeferred;
|
||||
|
||||
public void defer(@Nullable Runnable deferred) {
|
||||
this.deferred = deferred;
|
||||
executeIfNecessary();
|
||||
}
|
||||
|
||||
public void setDeferred(boolean isDeferred) {
|
||||
this.isDeferred = isDeferred;
|
||||
executeIfNecessary();
|
||||
}
|
||||
|
||||
public boolean isDeferred() {
|
||||
return isDeferred;
|
||||
}
|
||||
|
||||
private void executeIfNecessary() {
|
||||
if (deferred != null && !isDeferred) {
|
||||
Runnable local = deferred;
|
||||
|
||||
deferred = null;
|
||||
|
||||
local.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
class ConversationGroupViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<Recipient> liveRecipient;
|
||||
private final LiveData<GroupActiveState> groupActiveState;
|
||||
|
||||
private ConversationGroupViewModel() {
|
||||
liveRecipient = new MutableLiveData<>();
|
||||
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, this::getGroupRecordForRecipient);
|
||||
groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, this::mapToGroupActiveState));
|
||||
}
|
||||
|
||||
void onRecipientChange(Recipient recipient) {
|
||||
liveRecipient.setValue(recipient);
|
||||
}
|
||||
|
||||
LiveData<GroupActiveState> getGroupActiveState() {
|
||||
return groupActiveState;
|
||||
}
|
||||
|
||||
private GroupRecord getGroupRecordForRecipient(Recipient recipient) {
|
||||
if (recipient != null && recipient.isGroup()) {
|
||||
Application context = ApplicationDependencies.getApplication();
|
||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
return groupDatabase.getGroup(recipient.getId()).orNull();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private GroupActiveState mapToGroupActiveState(@Nullable GroupRecord record) {
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
return new GroupActiveState(record.isActive(), record.isV2Group());
|
||||
}
|
||||
|
||||
static final class GroupActiveState {
|
||||
private final boolean isActive;
|
||||
private final boolean isActiveV2;
|
||||
|
||||
public GroupActiveState(boolean isActive, boolean isV2) {
|
||||
this.isActive = isActive;
|
||||
this.isActiveV2 = isActive && isV2;
|
||||
}
|
||||
|
||||
public boolean isActiveGroup() {
|
||||
return isActive;
|
||||
}
|
||||
|
||||
public boolean isActiveV2Group() {
|
||||
return isActiveV2;
|
||||
}
|
||||
}
|
||||
|
||||
static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new ConversationGroupViewModel());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,7 @@ import org.thoughtcrime.securesms.reactions.ReactionsConversationView;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceMessageView;
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
||||
@@ -148,20 +149,20 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
|
||||
protected ConversationItemBodyBubble bodyBubble;
|
||||
protected View reply;
|
||||
protected ViewGroup contactPhotoHolder;
|
||||
private QuoteView quoteView;
|
||||
private EmojiTextView bodyText;
|
||||
private ConversationItemFooter footer;
|
||||
private ConversationItemFooter stickerFooter;
|
||||
private TextView groupSender;
|
||||
private TextView groupSenderProfileName;
|
||||
private View groupSenderHolder;
|
||||
private AvatarImageView contactPhoto;
|
||||
private AlertView alertView;
|
||||
private ViewGroup container;
|
||||
protected ReactionsConversationView reactionsView;
|
||||
protected ConversationItemBodyBubble bodyBubble;
|
||||
protected View reply;
|
||||
@Nullable protected ViewGroup contactPhotoHolder;
|
||||
@Nullable private QuoteView quoteView;
|
||||
private EmojiTextView bodyText;
|
||||
private ConversationItemFooter footer;
|
||||
private ConversationItemFooter stickerFooter;
|
||||
@Nullable private TextView groupSender;
|
||||
@Nullable private TextView groupSenderProfileName;
|
||||
@Nullable private View groupSenderHolder;
|
||||
private AvatarImageView contactPhoto;
|
||||
private AlertView alertView;
|
||||
private ViewGroup container;
|
||||
protected ReactionsConversationView reactionsView;
|
||||
|
||||
private @NonNull Set<MessageRecord> batchSelected = new HashSet<>();
|
||||
private @NonNull Outliner outliner = new Outliner();
|
||||
@@ -234,8 +235,6 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
|
||||
bodyText.setOnLongClickListener(passthroughClickListener);
|
||||
bodyText.setOnClickListener(passthroughClickListener);
|
||||
|
||||
bodyText.setMovementMethod(LongClickMovementMethod.getInstance(getContext()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -285,6 +284,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
ConversationSwipeAnimationHelper.update(this, 0f, 1f);
|
||||
unbind();
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
@@ -311,6 +311,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
boolean needsMeasure = false;
|
||||
|
||||
if (hasQuote(messageRecord)) {
|
||||
if (quoteView == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
int quoteWidth = quoteView.getMeasuredWidth();
|
||||
int availableWidth = getAvailableMessageBubbleWidth(quoteView);
|
||||
|
||||
@@ -343,9 +346,10 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient modified) {
|
||||
setBubbleState(messageRecord);
|
||||
setContactPhoto(recipient.get());
|
||||
setGroupMessageStatus(messageRecord, recipient.get());
|
||||
setAudioViewTint(messageRecord, conversationRecipient.get());
|
||||
if (recipient.getId().equals(modified.getId())) {
|
||||
setContactPhoto(modified);
|
||||
setGroupMessageStatus(messageRecord, modified);
|
||||
}
|
||||
}
|
||||
|
||||
private int getAvailableMessageBubbleWidth(@NonNull View forView) {
|
||||
@@ -376,6 +380,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
if (recipient != null) {
|
||||
recipient.removeForeverObserver(this);
|
||||
}
|
||||
if (conversationRecipient != null) {
|
||||
conversationRecipient.removeForeverObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
public MessageRecord getMessageRecord() {
|
||||
@@ -403,19 +410,21 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
bodyBubble.setOutliner(shouldDrawBodyBubbleOutline(messageRecord) ? outliner : null);
|
||||
|
||||
if (audioViewStub.resolved()) {
|
||||
setAudioViewTint(messageRecord, this.conversationRecipient.get());
|
||||
setAudioViewTint(messageRecord);
|
||||
}
|
||||
}
|
||||
|
||||
private void setAudioViewTint(MessageRecord messageRecord, Recipient recipient) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
if (DynamicTheme.isDarkTheme(context)) {
|
||||
audioViewStub.get().setTint(Color.WHITE, defaultBubbleColor);
|
||||
private void setAudioViewTint(MessageRecord messageRecord) {
|
||||
if (hasAudio(messageRecord)) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
if (DynamicTheme.isDarkTheme(context)) {
|
||||
audioViewStub.get().setTint(Color.WHITE);
|
||||
} else {
|
||||
audioViewStub.get().setTint(getContext().getResources().getColor(R.color.core_grey_60));
|
||||
}
|
||||
} else {
|
||||
audioViewStub.get().setTint(getContext().getResources().getColor(R.color.core_grey_60), defaultBubbleColor);
|
||||
audioViewStub.get().setTint(Color.WHITE);
|
||||
}
|
||||
} else {
|
||||
audioViewStub.get().setTint(Color.WHITE, recipient.getColor().toConversationColor(context));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,6 +529,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
bodyText.setClickable(false);
|
||||
bodyText.setFocusable(false);
|
||||
bodyText.setTextSize(TypedValue.COMPLEX_UNIT_SP, TextSecurePreferences.getMessageBodyTextSize(context));
|
||||
bodyText.setMovementMethod(LongClickMovementMethod.getInstance(getContext()));
|
||||
|
||||
if (messageRecord.isRemoteDelete()) {
|
||||
String deletedMessage = context.getString(R.string.ConversationItem_this_message_was_deleted);
|
||||
@@ -585,7 +595,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
setSharedContactCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
|
||||
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
footer.setVisibility(GONE);
|
||||
} else if (hasLinkPreview(messageRecord)) {
|
||||
linkPreviewStub.get().setVisibility(View.VISIBLE);
|
||||
@@ -612,13 +622,13 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, true);
|
||||
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
} else {
|
||||
linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, true);
|
||||
linkPreviewStub.get().setDownloadClickedListener(downloadClickListener);
|
||||
setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, false);
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
|
||||
linkPreviewStub.get().setOnClickListener(linkPreviewClickListener);
|
||||
@@ -641,7 +651,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
footer.setVisibility(VISIBLE);
|
||||
} else if (hasDocument(messageRecord)) {
|
||||
@@ -660,7 +670,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
documentViewStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
footer.setVisibility(VISIBLE);
|
||||
} else if (hasSticker(messageRecord) && isCaptionlessMms(messageRecord)) {
|
||||
@@ -682,7 +692,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
stickerStub.get().setOnClickListener(passthroughClickListener);
|
||||
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
footer.setVisibility(VISIBLE);
|
||||
} else if (hasThumbnail(messageRecord)) {
|
||||
@@ -712,7 +722,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
|
||||
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
footer.setVisibility(VISIBLE);
|
||||
} else {
|
||||
@@ -725,7 +735,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
footer.setVisibility(VISIBLE);
|
||||
}
|
||||
@@ -823,7 +833,16 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
|
||||
private void setContactPhoto(@NonNull Recipient recipient) {
|
||||
if (contactPhoto == null) return;
|
||||
contactPhoto.setAvatar(glideRequests, recipient, true);
|
||||
|
||||
final RecipientId recipientId = recipient.getId();
|
||||
|
||||
contactPhoto.setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
eventListener.onGroupMemberAvatarClicked(recipientId, conversationRecipient.get().requireGroupId());
|
||||
}
|
||||
});
|
||||
|
||||
contactPhoto.setAvatar(glideRequests, recipient, false);
|
||||
}
|
||||
|
||||
private SpannableString linkifyMessageBody(SpannableString messageBody, boolean shouldLinkifyAllLinks) {
|
||||
@@ -860,6 +879,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
|
||||
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
|
||||
if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) {
|
||||
if (quoteView == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
Quote quote = ((MediaMmsMessageRecord)current).getQuote();
|
||||
//noinspection ConstantConditions
|
||||
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getText(), quote.isOriginalMissing(), quote.getAttachment());
|
||||
@@ -896,7 +918,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
ViewUtil.setTopMargin(mediaThumbnailStub.get(), readDimen(R.dimen.message_bubble_top_padding));
|
||||
}
|
||||
} else {
|
||||
quoteView.dismiss();
|
||||
if (quoteView != null) {
|
||||
quoteView.dismiss();
|
||||
}
|
||||
|
||||
if (mediaThumbnailStub.resolved()) {
|
||||
ViewUtil.setTopMargin(mediaThumbnailStub.get(), 0);
|
||||
@@ -977,38 +1001,42 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private void setGroupMessageStatus(MessageRecord messageRecord, Recipient recipient) {
|
||||
if (groupThread && !messageRecord.isOutgoing()) {
|
||||
if (groupThread && !messageRecord.isOutgoing() && groupSender != null && groupSenderProfileName != null) {
|
||||
|
||||
if (FeatureFlags.profileDisplay()) {
|
||||
this.groupSender.setText(recipient.getDisplayName(getContext()));
|
||||
this.groupSenderProfileName.setVisibility(View.GONE);
|
||||
groupSender.setText(recipient.getDisplayName(getContext()));
|
||||
groupSenderProfileName.setVisibility(View.GONE);
|
||||
} else {
|
||||
this.groupSender.setText(recipient.toShortString(context));
|
||||
groupSender.setText(recipient.toShortString(context));
|
||||
|
||||
if (recipient.getName(context) == null && !recipient.getProfileName().isEmpty()) {
|
||||
this.groupSenderProfileName.setText("~" + recipient.getProfileName().toString());
|
||||
this.groupSenderProfileName.setVisibility(View.VISIBLE);
|
||||
groupSenderProfileName.setText("~" + recipient.getProfileName().toString());
|
||||
groupSenderProfileName.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
this.groupSenderProfileName.setText(null);
|
||||
this.groupSenderProfileName.setVisibility(View.GONE);
|
||||
groupSenderProfileName.setText(null);
|
||||
groupSenderProfileName.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord) {
|
||||
if (shouldDrawBodyBubbleOutline(messageRecord)) {
|
||||
groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
|
||||
groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
|
||||
} else if (hasSticker(messageRecord)) {
|
||||
groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
|
||||
groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
|
||||
} else {
|
||||
groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_primary_color));
|
||||
groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_primary_color));
|
||||
if (groupSender != null && groupSenderProfileName != null) {
|
||||
int stickerAuthorColor = ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color);
|
||||
if (shouldDrawBodyBubbleOutline(messageRecord)) {
|
||||
groupSender.setTextColor(stickerAuthorColor);
|
||||
groupSenderProfileName.setTextColor(stickerAuthorColor);
|
||||
} else if (hasSticker(messageRecord)) {
|
||||
groupSender.setTextColor(stickerAuthorColor);
|
||||
groupSenderProfileName.setTextColor(stickerAuthorColor);
|
||||
} else {
|
||||
groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_primary_color));
|
||||
groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_primary_color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
private void setAuthor(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
|
||||
if (isGroupThread && !current.isOutgoing()) {
|
||||
contactPhotoHolder.setVisibility(VISIBLE);
|
||||
@@ -1027,7 +1055,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
contactPhoto.setVisibility(GONE);
|
||||
}
|
||||
} else {
|
||||
groupSenderHolder.setVisibility(GONE);
|
||||
if (groupSenderHolder != null) {
|
||||
groupSenderHolder.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (contactPhotoHolder != null) {
|
||||
contactPhotoHolder.setVisibility(GONE);
|
||||
@@ -1405,8 +1435,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
database.markAsOutbox(messageRecord.getId());
|
||||
database.markAsForcedSms(messageRecord.getId());
|
||||
|
||||
ApplicationDependencies.getJobManager().add(new SmsSendJob(context,
|
||||
messageRecord.getId(),
|
||||
ApplicationDependencies.getJobManager().add(new SmsSendJob(messageRecord.getId(),
|
||||
messageRecord.getIndividualRecipient()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.widget.RelativeLayout;
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
@@ -30,14 +31,17 @@ import com.annimon.stream.Stream;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.components.MaskView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
@@ -60,12 +64,13 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
private boolean downIsOurs;
|
||||
private boolean isToolbarTouch;
|
||||
private int selected = -1;
|
||||
private int customEmojiIndex;
|
||||
private int originalStatusBarColor;
|
||||
|
||||
private View backgroundView;
|
||||
private ConstraintLayout foregroundView;
|
||||
private View selectedView;
|
||||
private View[] emojiViews;
|
||||
private EmojiImageView[] emojiViews;
|
||||
private MaskView maskView;
|
||||
private Toolbar toolbar;
|
||||
|
||||
@@ -85,8 +90,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener;
|
||||
private OnHideListener onHideListener;
|
||||
|
||||
private AnimatorSet revealAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet revealAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAllButMaskAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideMaskAnimatorSet = new AnimatorSet();
|
||||
|
||||
public ConversationReactionOverlay(@NonNull Context context) {
|
||||
super(context);
|
||||
@@ -111,7 +118,9 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
|
||||
emojiViews = Stream.of(ReactionEmoji.values())
|
||||
.map(e -> findViewById(e.viewId))
|
||||
.toArray(View[]::new);
|
||||
.toArray(EmojiImageView[]::new);
|
||||
|
||||
customEmojiIndex = ReactionEmoji.values().length - 1;
|
||||
|
||||
distanceFromTouchDownPointToTopOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_top);
|
||||
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
|
||||
@@ -144,7 +153,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
selected = -1;
|
||||
|
||||
setupToolbarMenuItems();
|
||||
setupSelectedEmojiBackground();
|
||||
setupSelectedEmoji();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
|
||||
@@ -188,6 +197,22 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
|
||||
public void hide() {
|
||||
maskView.setTarget(null);
|
||||
hideInternal(hideAnimatorSet, onHideListener);
|
||||
}
|
||||
|
||||
public void hideAllButMask() {
|
||||
hideInternal(hideAllButMaskAnimatorSet, null);
|
||||
}
|
||||
|
||||
public void hideMask() {
|
||||
hideMaskAnimatorSet.start();
|
||||
|
||||
if (onHideListener != null) {
|
||||
onHideListener.onHide();
|
||||
}
|
||||
}
|
||||
|
||||
private void hideInternal(@NonNull AnimatorSet hideAnimatorSet, @Nullable OnHideListener onHideListener) {
|
||||
overlayState = OverlayState.HIDDEN;
|
||||
|
||||
revealAnimatorSet.end();
|
||||
@@ -316,20 +341,30 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void setupSelectedEmojiBackground() {
|
||||
private void setupSelectedEmoji() {
|
||||
final String oldEmoji = getOldEmoji(messageRecord);
|
||||
|
||||
if (oldEmoji == null) {
|
||||
selectedView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
boolean foundSelected = false;
|
||||
|
||||
for (int i = 0; i < emojiViews.length; i++) {
|
||||
final View view = emojiViews[i];
|
||||
final EmojiImageView view = emojiViews[i];
|
||||
|
||||
view.setScaleX(1.0f);
|
||||
view.setScaleY(1.0f);
|
||||
view.setTranslationY(0);
|
||||
|
||||
if (ReactionEmoji.values()[i].emoji.equals(oldEmoji)) {
|
||||
boolean isAtCustomIndex = i == customEmojiIndex;
|
||||
boolean isNotAtCustomIndexAndOldEmojiMatches = !isAtCustomIndex && ReactionEmoji.values()[i].emoji.equals(oldEmoji);
|
||||
boolean isAtCustomIndexAndOldEmojiExists = isAtCustomIndex && oldEmoji != null;
|
||||
|
||||
if (!foundSelected &&
|
||||
(isNotAtCustomIndexAndOldEmojiMatches || isAtCustomIndexAndOldEmojiExists))
|
||||
{
|
||||
foundSelected = true;
|
||||
selectedView.setVisibility(View.VISIBLE);
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
@@ -339,6 +374,18 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
constraintSet.connect(selectedView.getId(), ConstraintSet.LEFT, view.getId(), ConstraintSet.LEFT);
|
||||
constraintSet.connect(selectedView.getId(), ConstraintSet.RIGHT, view.getId(), ConstraintSet.RIGHT);
|
||||
constraintSet.applyTo(foregroundView);
|
||||
|
||||
if (isAtCustomIndex) {
|
||||
view.setImageEmoji(oldEmoji);
|
||||
view.setTag(oldEmoji);
|
||||
} else {
|
||||
view.setImageEmoji(ReactionEmoji.values()[i].emoji);
|
||||
}
|
||||
} else if (isAtCustomIndex) {
|
||||
view.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.ic_any_emoji_32));
|
||||
view.setTag(null);
|
||||
} else {
|
||||
view.setImageEmoji(ReactionEmoji.values()[i].emoji);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,9 +443,14 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
}
|
||||
|
||||
private void handleUpEvent() {
|
||||
hide();
|
||||
if (selected != -1 && onReactionSelectedListener != null) {
|
||||
onReactionSelectedListener.onReactionSelected(messageRecord, ReactionEmoji.values()[selected].emoji);
|
||||
if (selected == customEmojiIndex) {
|
||||
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
|
||||
} else {
|
||||
onReactionSelectedListener.onReactionSelected(messageRecord, ReactionEmoji.values()[selected].emoji);
|
||||
}
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +546,6 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
overlayHideAnim.setTarget(maskView);
|
||||
overlayHideAnim.setDuration(duration);
|
||||
hides.add(overlayHideAnim);
|
||||
|
||||
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
backgroundHideAnim.setTarget(backgroundView);
|
||||
@@ -511,15 +562,26 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
toolbarHideAnim.setDuration(duration);
|
||||
hides.add(toolbarHideAnim);
|
||||
|
||||
hideAnimatorSet.addListener(new AnimationCompleteListener() {
|
||||
AnimationCompleteListener hideListener = new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
List<Animator> hideAllAnimators = new LinkedList<>(hides);
|
||||
hideAllAnimators.add(overlayHideAnim);
|
||||
|
||||
hideAnimatorSet.addListener(hideListener);
|
||||
hideAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
hideAnimatorSet.playTogether(hides);
|
||||
hideAnimatorSet.playTogether(hideAllAnimators);
|
||||
|
||||
hideAllButMaskAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
hideAllButMaskAnimatorSet.playTogether(hides);
|
||||
|
||||
hideMaskAnimatorSet.addListener(hideListener);
|
||||
hideMaskAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
hideMaskAnimatorSet.playTogether(overlayHideAnim);
|
||||
}
|
||||
|
||||
public interface OnHideListener {
|
||||
@@ -528,6 +590,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
|
||||
public interface OnReactionSelectedListener {
|
||||
void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji);
|
||||
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
|
||||
}
|
||||
|
||||
private static class Boundary {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
class ConversationRepository {
|
||||
|
||||
private final Context context;
|
||||
private final Executor executor;
|
||||
|
||||
ConversationRepository() {
|
||||
this.context = ApplicationDependencies.getApplication();
|
||||
this.executor = SignalExecutors.BOUNDED;
|
||||
}
|
||||
|
||||
LiveData<ConversationData> getConversationData(long threadId, int jumpToPosition) {
|
||||
MutableLiveData<ConversationData> liveData = new MutableLiveData<>();
|
||||
|
||||
executor.execute(() -> {
|
||||
liveData.postValue(getConversationDataInternal(threadId, jumpToPosition));
|
||||
});
|
||||
|
||||
return liveData;
|
||||
}
|
||||
|
||||
private @NonNull ConversationData getConversationDataInternal(long threadId, int jumpToPosition) {
|
||||
Pair<Long, Boolean> lastSeenAndHasSent = DatabaseFactory.getThreadDatabase(context).getLastSeenAndHasSent(threadId);
|
||||
|
||||
long lastSeen = lastSeenAndHasSent.first();
|
||||
boolean hasSent = lastSeenAndHasSent.second();
|
||||
int lastSeenPosition = 0;
|
||||
|
||||
boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, threadId);
|
||||
boolean hasPreMessageRequestMessages = RecipientUtil.isPreMessageRequestThread(context, threadId);
|
||||
|
||||
if (lastSeen > 0) {
|
||||
lastSeenPosition = DatabaseFactory.getMmsSmsDatabase(context).getMessagePositionForLastSeen(threadId, lastSeen);
|
||||
}
|
||||
|
||||
if (lastSeenPosition <= 0) {
|
||||
lastSeen = 0;
|
||||
}
|
||||
|
||||
return new ConversationData(threadId, lastSeen, lastSeenPosition, hasSent, isMessageRequestAccepted, hasPreMessageRequestMessages, jumpToPosition);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,6 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
@@ -12,6 +9,9 @@ import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
@@ -22,7 +22,6 @@ import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
@@ -107,18 +106,6 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
updateVerifiedSubtitleVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnClickListener(@Nullable OnClickListener listener) {
|
||||
this.content.setOnClickListener(listener);
|
||||
this.avatar.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnLongClickListener(@Nullable OnLongClickListener listener) {
|
||||
this.content.setOnLongClickListener(listener);
|
||||
this.avatar.setOnLongClickListener(listener);
|
||||
}
|
||||
|
||||
private void setComposeTitle() {
|
||||
this.title.setText(R.string.ConversationActivity_compose_message);
|
||||
this.subtitle.setText(null);
|
||||
@@ -164,8 +151,6 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
}
|
||||
|
||||
private void setGroupRecipientTitle(Recipient recipient) {
|
||||
String localNumber = TextSecurePreferences.getLocalNumber(getContext());
|
||||
|
||||
if (FeatureFlags.profileDisplay()) {
|
||||
this.title.setText(recipient.getDisplayName(getContext()));
|
||||
} else {
|
||||
@@ -173,8 +158,9 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
}
|
||||
|
||||
this.subtitle.setText(Stream.of(recipient.getParticipants())
|
||||
.filterNot(Recipient::isLocalNumber)
|
||||
.map(r -> r.toShortString(getContext()))
|
||||
.sorted((a, b) -> Boolean.compare(a.isLocalNumber(), b.isLocalNumber()))
|
||||
.map(r -> r.isLocalNumber() ? getResources().getString(R.string.ConversationTitleView_you)
|
||||
: r.getDisplayName(getContext()))
|
||||
.collect(Collectors.joining(", ")));
|
||||
|
||||
updateSubtitleVisibility();
|
||||
|
||||
@@ -87,6 +87,12 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
bind(messageRecord, locale);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
unbind();
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEventListener(@Nullable EventListener listener) {
|
||||
// No events to report yet
|
||||
@@ -103,7 +109,7 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
}
|
||||
|
||||
if (this.messageRecord != null && messageRecord.isGroupAction()) {
|
||||
GroupUtil.getDescription(getContext(), messageRecord.getBody()).removeObserver(this);
|
||||
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).removeObserver(this);
|
||||
}
|
||||
|
||||
this.messageRecord = messageRecord;
|
||||
@@ -113,7 +119,7 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
this.sender.observeForever(this);
|
||||
|
||||
if (this.messageRecord != null && messageRecord.isGroupAction()) {
|
||||
GroupUtil.getDescription(getContext(), messageRecord.getBody()).addObserver(this);
|
||||
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).addObserver(this);
|
||||
}
|
||||
|
||||
present(messageRecord);
|
||||
@@ -236,7 +242,7 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
sender.removeForeverObserver(this);
|
||||
}
|
||||
if (this.messageRecord != null && messageRecord.isGroupAction()) {
|
||||
GroupUtil.getDescription(getContext(), messageRecord.getBody()).removeObserver(this);
|
||||
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).removeObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,145 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.arch.core.util.Function;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MediatorLiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.paging.DataSource;
|
||||
import androidx.paging.LivePagedListBuilder;
|
||||
import androidx.paging.PagedList;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.ConversationDataSource.Invalidator;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaRepository;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
class ConversationViewModel extends ViewModel {
|
||||
|
||||
private final Context context;
|
||||
private final MediaRepository mediaRepository;
|
||||
private final MutableLiveData<List<Media>> recentMedia;
|
||||
private static final String TAG = Log.tag(ConversationViewModel.class);
|
||||
|
||||
private final Application context;
|
||||
private final MediaRepository mediaRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final MutableLiveData<List<Media>> recentMedia;
|
||||
private final MutableLiveData<Long> threadId;
|
||||
private final LiveData<PagedList<MessageRecord>> messages;
|
||||
private final LiveData<ConversationData> conversationMetadata;
|
||||
private final List<Runnable> onNextMessageLoad;
|
||||
private final Invalidator invalidator;
|
||||
|
||||
private int jumpToPosition;
|
||||
|
||||
private ConversationViewModel() {
|
||||
this.context = ApplicationDependencies.getApplication();
|
||||
this.mediaRepository = new MediaRepository();
|
||||
this.recentMedia = new MutableLiveData<>();
|
||||
this.context = ApplicationDependencies.getApplication();
|
||||
this.mediaRepository = new MediaRepository();
|
||||
this.conversationRepository = new ConversationRepository();
|
||||
this.recentMedia = new MutableLiveData<>();
|
||||
this.threadId = new MutableLiveData<>();
|
||||
this.onNextMessageLoad = new CopyOnWriteArrayList<>();
|
||||
this.invalidator = new Invalidator();
|
||||
|
||||
LiveData<ConversationData> conversationDataForRequestedThreadId = Transformations.switchMap(threadId, thread -> {
|
||||
return conversationRepository.getConversationData(thread, jumpToPosition);
|
||||
});
|
||||
|
||||
LiveData<Pair<Long, PagedList<MessageRecord>>> messagesForThreadId = Transformations.switchMap(conversationDataForRequestedThreadId, data -> {
|
||||
DataSource.Factory<Integer, MessageRecord> factory = new ConversationDataSource.Factory(context, data.getThreadId(), invalidator, this::onMessagesUpdated);
|
||||
PagedList.Config config = new PagedList.Config.Builder()
|
||||
.setPageSize(25)
|
||||
.setInitialLoadSizeHint(25)
|
||||
.build();
|
||||
|
||||
final int startPosition;
|
||||
if (jumpToPosition > 0) {
|
||||
startPosition = jumpToPosition;
|
||||
} else {
|
||||
startPosition = data.getLastSeenPosition();
|
||||
}
|
||||
|
||||
Log.d(TAG, "Starting at position " + startPosition + " :: " + jumpToPosition + " :: " + data.getLastSeenPosition());
|
||||
|
||||
return Transformations.map(new LivePagedListBuilder<>(factory, config).setFetchExecutor(ConversationDataSource.EXECUTOR)
|
||||
.setInitialLoadKey(Math.max(startPosition, 0))
|
||||
.build(),
|
||||
input -> new Pair<>(data.getThreadId(), input));
|
||||
});
|
||||
|
||||
this.messages = Transformations.map(messagesForThreadId, Pair::second);
|
||||
|
||||
LiveData<Long> threadIdForLoadedMessages = Transformations.distinctUntilChanged(Transformations.map(messagesForThreadId, Pair::first));
|
||||
|
||||
conversationMetadata = Transformations.switchMap(threadIdForLoadedMessages, m -> {
|
||||
LiveData<ConversationData> data = conversationRepository.getConversationData(m, jumpToPosition);
|
||||
jumpToPosition = -1;
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
void onAttachmentKeyboardOpen() {
|
||||
mediaRepository.getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, recentMedia::postValue);
|
||||
}
|
||||
|
||||
void onConversationDataAvailable(long threadId, int startingPosition) {
|
||||
Log.d(TAG, "[onConversationDataAvailable] threadId: " + threadId + ", startingPosition: " + startingPosition);
|
||||
this.jumpToPosition = startingPosition;
|
||||
|
||||
this.threadId.setValue(threadId);
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<Media>> getRecentMedia() {
|
||||
return recentMedia;
|
||||
}
|
||||
|
||||
@NonNull LiveData<ConversationData> getConversationMetadata() {
|
||||
return conversationMetadata;
|
||||
}
|
||||
|
||||
@NonNull LiveData<PagedList<MessageRecord>> getMessages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
long getLastSeen() {
|
||||
return conversationMetadata.getValue() != null ? conversationMetadata.getValue().getLastSeen() : 0;
|
||||
}
|
||||
|
||||
int getLastSeenPosition() {
|
||||
return conversationMetadata.getValue() != null ? conversationMetadata.getValue().getLastSeenPosition() : 0;
|
||||
}
|
||||
|
||||
void scheduleForNextMessageUpdate(@NonNull Runnable runnable) {
|
||||
onNextMessageLoad.add(runnable);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
super.onCleared();
|
||||
invalidator.invalidate();
|
||||
}
|
||||
|
||||
private void onMessagesUpdated() {
|
||||
for (Runnable runnable : onNextMessageLoad) {
|
||||
runnable.run();
|
||||
}
|
||||
|
||||
onNextMessageLoad.clear();
|
||||
}
|
||||
|
||||
static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||
@Override
|
||||
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
|
||||
class LastSeenHeader extends StickyHeaderDecoration {
|
||||
|
||||
private final ConversationAdapter adapter;
|
||||
private final long lastSeenTimestamp;
|
||||
|
||||
LastSeenHeader(ConversationAdapter adapter, long lastSeenTimestamp) {
|
||||
super(adapter, false, false);
|
||||
this.adapter = adapter;
|
||||
this.lastSeenTimestamp = lastSeenTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
|
||||
if (lastSeenTimestamp <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long currentRecordTimestamp = adapter.getReceivedTimestamp(position);
|
||||
long previousRecordTimestamp = adapter.getReceivedTimestamp(position + 1);
|
||||
|
||||
return currentRecordTimestamp > lastSeenTimestamp && previousRecordTimestamp < lastSeenTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, int layoutPos) {
|
||||
return parent.getLayoutManager().getDecoratedTop(child);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @NonNull RecyclerView.ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
|
||||
StickyHeaderViewHolder viewHolder = new StickyHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_item_last_seen, parent, false));
|
||||
adapter.onBindLastSeenViewHolder(viewHolder, position);
|
||||
|
||||
int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
|
||||
int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
|
||||
|
||||
int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.getPaddingLeft() + parent.getPaddingRight(), viewHolder.itemView.getLayoutParams().width);
|
||||
int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.getPaddingTop() + parent.getPaddingBottom(), viewHolder.itemView.getLayoutParams().height);
|
||||
|
||||
viewHolder.itemView.measure(childWidth, childHeight);
|
||||
viewHolder.itemView.layout(0, 0, viewHolder.itemView.getMeasuredWidth(), viewHolder.itemView.getMeasuredHeight());
|
||||
|
||||
return viewHolder;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.BindableConversationListItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||
@@ -37,8 +40,10 @@ import org.thoughtcrime.securesms.util.Conversions;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -59,9 +64,9 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
|
||||
private final @Nullable ItemClickListener clickListener;
|
||||
private final @NonNull MessageDigest digest;
|
||||
|
||||
private final Set<Long> batchSet = Collections.synchronizedSet(new HashSet<Long>());
|
||||
private boolean batchMode = false;
|
||||
private final Set<Long> typingSet = new HashSet<>();
|
||||
private final Map<Long, ThreadRecord> batchSet = Collections.synchronizedMap(new HashMap<>());
|
||||
private boolean batchMode = false;
|
||||
private final Set<Long> typingSet = new HashSet<>();
|
||||
|
||||
protected static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
public <V extends View & BindableConversationListItem> ViewHolder(final @NonNull V itemView)
|
||||
@@ -143,7 +148,7 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
|
||||
|
||||
@Override
|
||||
public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) {
|
||||
viewHolder.getItem().bind(getThreadRecord(cursor), glideRequests, locale, typingSet, batchSet, batchMode);
|
||||
viewHolder.getItem().bind(getThreadRecord(cursor), glideRequests, locale, typingSet, batchSet.keySet(), batchMode);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -169,16 +174,20 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
|
||||
return threadDatabase.readerFor(cursor).getCurrent();
|
||||
}
|
||||
|
||||
void toggleThreadInBatchSet(long threadId) {
|
||||
if (batchSet.contains(threadId)) {
|
||||
batchSet.remove(threadId);
|
||||
} else if (threadId != -1) {
|
||||
batchSet.add(threadId);
|
||||
void toggleThreadInBatchSet(@NonNull ThreadRecord thread) {
|
||||
if (batchSet.containsKey(thread.getThreadId())) {
|
||||
batchSet.remove(thread.getThreadId());
|
||||
} else if (thread.getThreadId() != -1) {
|
||||
batchSet.put(thread.getThreadId(), thread);
|
||||
}
|
||||
}
|
||||
|
||||
Set<Long> getBatchSelections() {
|
||||
return batchSet;
|
||||
@NonNull Set<Long> getBatchSelectionIds() {
|
||||
return batchSet.keySet();
|
||||
}
|
||||
|
||||
@NonNull Set<ThreadRecord> getBatchSelection() {
|
||||
return new HashSet<>(batchSet.values());
|
||||
}
|
||||
|
||||
void initializeBatchMode(boolean toggle) {
|
||||
@@ -193,8 +202,10 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
|
||||
|
||||
void selectAllThreads() {
|
||||
for (int i = 0; i < getItemCount(); i++) {
|
||||
long threadId = getThreadRecord(getCursorAtPositionOrThrow(i)).getThreadId();
|
||||
if (threadId != -1) batchSet.add(threadId);
|
||||
ThreadRecord record = getThreadRecord(getCursorAtPositionOrThrow(i));
|
||||
if (record.getThreadId() != -1) {
|
||||
batchSet.put(record.getThreadId(), record);
|
||||
}
|
||||
}
|
||||
this.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
@@ -102,7 +103,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
|
||||
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
|
||||
import org.thoughtcrime.securesms.lock.RegistrationLockV1Dialog;
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
|
||||
@@ -112,11 +112,11 @@ import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder;
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
@@ -126,6 +126,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Collections;
|
||||
@@ -238,8 +239,6 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
|
||||
RatingManager.showRatingDialogIfNecessary(requireContext());
|
||||
|
||||
RegistrationLockV1Dialog.showReminderIfNecessary(this);
|
||||
|
||||
TooltipCompat.setTooltipText(searchAction, getText(R.string.SearchToolbar_search_for_conversations_contacts_and_messages));
|
||||
}
|
||||
|
||||
@@ -264,6 +263,13 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
// TODO [greyson] Re-enable when we figure out how to invalidate the cache after a system theme change
|
||||
// ConversationFragment.prepare(requireContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
@@ -347,7 +353,6 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
getNavigator().goToConversation(threadRecord.getRecipient().getId(),
|
||||
threadRecord.getThreadId(),
|
||||
threadRecord.getDistributionType(),
|
||||
threadRecord.getLastSeen(),
|
||||
-1);
|
||||
}
|
||||
|
||||
@@ -360,7 +365,6 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
getNavigator().goToConversation(contact.getId(),
|
||||
threadId,
|
||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
-1,
|
||||
-1);
|
||||
});
|
||||
}
|
||||
@@ -375,7 +379,6 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
getNavigator().goToConversation(message.conversationRecipient.getId(),
|
||||
message.threadId,
|
||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
-1,
|
||||
startingPosition);
|
||||
});
|
||||
}
|
||||
@@ -585,11 +588,46 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setAllThreadsRead();
|
||||
|
||||
MessageNotifier.updateNotification(context);
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context);
|
||||
MarkReadReceiver.process(context, messageIds);
|
||||
});
|
||||
}
|
||||
|
||||
private void handleMarkSelectedAsRead() {
|
||||
Context context = requireContext();
|
||||
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setRead(selectedConversations, false);
|
||||
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context);
|
||||
MarkReadReceiver.process(context, messageIds);
|
||||
|
||||
return null;
|
||||
}, none -> {
|
||||
if (actionMode != null) {
|
||||
actionMode.finish();
|
||||
actionMode = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleMarkSelectedAsUnread() {
|
||||
Context context = requireContext();
|
||||
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
DatabaseFactory.getThreadDatabase(context).setForcedUnread(selectedConversations);
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
return null;
|
||||
}, none -> {
|
||||
if (actionMode != null) {
|
||||
actionMode.finish();
|
||||
actionMode = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleInvite() {
|
||||
getNavigator().goToInvite();
|
||||
}
|
||||
@@ -600,7 +638,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleArchiveAllSelected() {
|
||||
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelections());
|
||||
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
|
||||
int count = selectedConversations.size();
|
||||
String snackBarTitle = getResources().getQuantityString(getArchivedSnackbarTitleRes(), count, count);
|
||||
|
||||
@@ -639,7 +677,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleDeleteAllSelected() {
|
||||
int conversationsCount = defaultAdapter.getBatchSelections().size();
|
||||
int conversationsCount = defaultAdapter.getBatchSelectionIds().size();
|
||||
AlertDialog.Builder alert = new AlertDialog.Builder(getActivity());
|
||||
alert.setIconAttribute(R.attr.dialog_alert_icon);
|
||||
alert.setTitle(getActivity().getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations,
|
||||
@@ -649,7 +687,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
alert.setCancelable(true);
|
||||
|
||||
alert.setPositiveButton(R.string.delete, (dialog, which) -> {
|
||||
final Set<Long> selectedConversations = defaultAdapter.getBatchSelections();
|
||||
final Set<Long> selectedConversations = defaultAdapter.getBatchSelectionIds();
|
||||
|
||||
if (!selectedConversations.isEmpty()) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@@ -666,7 +704,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
DatabaseFactory.getThreadDatabase(getActivity()).deleteConversations(selectedConversations);
|
||||
MessageNotifier.updateNotification(getActivity());
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -688,11 +726,11 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
|
||||
private void handleSelectAllThreads() {
|
||||
defaultAdapter.selectAllThreads();
|
||||
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelections().size()));
|
||||
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size()));
|
||||
}
|
||||
|
||||
private void handleCreateConversation(long threadId, Recipient recipient, int distributionType, long lastSeen) {
|
||||
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, lastSeen, -1);
|
||||
private void handleCreateConversation(long threadId, Recipient recipient, int distributionType) {
|
||||
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -726,16 +764,16 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
@Override
|
||||
public void onItemClick(ConversationListItem item) {
|
||||
if (actionMode == null) {
|
||||
handleCreateConversation(item.getThreadId(), item.getRecipient(),
|
||||
item.getDistributionType(), item.getLastSeen());
|
||||
handleCreateConversation(item.getThreadId(), item.getRecipient(), item.getDistributionType());
|
||||
} else {
|
||||
ConversationListAdapter adapter = (ConversationListAdapter)list.getAdapter();
|
||||
adapter.toggleThreadInBatchSet(item.getThreadId());
|
||||
adapter.toggleThreadInBatchSet(item.getThread());
|
||||
|
||||
if (adapter.getBatchSelections().size() == 0) {
|
||||
if (adapter.getBatchSelectionIds().size() == 0) {
|
||||
actionMode.finish();
|
||||
} else {
|
||||
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelections().size()));
|
||||
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size()));
|
||||
setCorrectMenuVisibility(actionMode.getMenu());
|
||||
}
|
||||
|
||||
adapter.notifyDataSetChanged();
|
||||
@@ -747,7 +785,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this);
|
||||
|
||||
defaultAdapter.initializeBatchMode(true);
|
||||
defaultAdapter.toggleThreadInBatchSet(item.getThreadId());
|
||||
defaultAdapter.toggleThreadInBatchSet(item.getThread());
|
||||
defaultAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@@ -779,15 +817,18 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
setCorrectMenuVisibility(menu);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.menu_select_all: handleSelectAllThreads(); return true;
|
||||
case R.id.menu_delete_selected: handleDeleteAllSelected(); return true;
|
||||
case R.id.menu_archive_selected: handleArchiveAllSelected(); return true;
|
||||
case R.id.menu_select_all: handleSelectAllThreads(); return true;
|
||||
case R.id.menu_delete_selected: handleDeleteAllSelected(); return true;
|
||||
case R.id.menu_archive_selected: handleArchiveAllSelected(); return true;
|
||||
case R.id.menu_mark_as_read: handleMarkSelectedAsRead(); return true;
|
||||
case R.id.menu_mark_as_unread: handleMarkSelectedAsUnread(); return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -828,6 +869,18 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
closeSearchIfOpen();
|
||||
}
|
||||
|
||||
private void setCorrectMenuVisibility(@NonNull Menu menu) {
|
||||
boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(thread -> !thread.isRead());
|
||||
|
||||
if (hasUnread) {
|
||||
menu.findItem(R.id.menu_mark_as_unread).setVisible(false);
|
||||
menu.findItem(R.id.menu_mark_as_read).setVisible(true);
|
||||
} else {
|
||||
menu.findItem(R.id.menu_mark_as_unread).setVisible(true);
|
||||
menu.findItem(R.id.menu_mark_as_read).setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected @IdRes int getToolbarRes() {
|
||||
return R.id.toolbar;
|
||||
}
|
||||
@@ -868,7 +921,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
|
||||
if (unreadCount > 0) {
|
||||
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(getActivity()).setRead(threadId, false);
|
||||
MessageNotifier.updateNotification(getActivity());
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
|
||||
MarkReadReceiver.process(getActivity(), messageIds);
|
||||
}
|
||||
}
|
||||
@@ -879,7 +932,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
|
||||
|
||||
if (unreadCount > 0) {
|
||||
DatabaseFactory.getThreadDatabase(getActivity()).incrementUnread(threadId, unreadCount);
|
||||
MessageNotifier.updateNotification(getActivity());
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
|
||||
}
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadId);
|
||||
|
||||
@@ -22,6 +22,7 @@ import android.graphics.Typeface;
|
||||
import android.graphics.drawable.RippleDrawable;
|
||||
import android.os.Build.VERSION;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.util.AttributeSet;
|
||||
@@ -41,15 +42,22 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView;
|
||||
import org.thoughtcrime.securesms.components.FromTextView;
|
||||
import org.thoughtcrime.securesms.components.ThumbnailView;
|
||||
import org.thoughtcrime.securesms.components.TypingIndicatorView;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.SearchUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
@@ -84,6 +92,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
private TextView unreadIndicator;
|
||||
private long lastSeen;
|
||||
private ThreadRecord thread;
|
||||
private boolean batchMode;
|
||||
|
||||
private int unreadCount;
|
||||
private AvatarImageView contactPhotoImage;
|
||||
@@ -93,7 +102,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
|
||||
private final RecipientForeverObserver groupAddedByObserver = adder -> {
|
||||
if (isAttachedToWindow() && subjectView != null && thread != null) {
|
||||
subjectView.setText(thread.getDisplayBody(getContext()));
|
||||
subjectView.setText(getThreadDisplayBody(getContext(), thread));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -162,7 +171,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
|
||||
this.fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), name, highlightSubstring));
|
||||
} else {
|
||||
this.fromView.setText(recipient.get(), unreadCount == 0);
|
||||
this.fromView.setText(recipient.get(), thread.isRead());
|
||||
}
|
||||
|
||||
if (typingThreads.contains(threadId)) {
|
||||
@@ -175,24 +184,24 @@ public class ConversationListItem extends RelativeLayout
|
||||
this.typingView.stopAnimation();
|
||||
|
||||
this.subjectView.setVisibility(VISIBLE);
|
||||
this.subjectView.setText(getTrimmedSnippet(thread.getDisplayBody(getContext())));
|
||||
this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread)));
|
||||
|
||||
if (thread.getGroupAddedBy() != null) {
|
||||
groupAddedBy = Recipient.live(thread.getGroupAddedBy());
|
||||
groupAddedBy.observeForever(groupAddedByObserver);
|
||||
}
|
||||
|
||||
this.subjectView.setTypeface(unreadCount == 0 ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
|
||||
this.subjectView.setTextColor(unreadCount == 0 ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)
|
||||
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
|
||||
this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
|
||||
this.subjectView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)
|
||||
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
|
||||
}
|
||||
|
||||
if (thread.getDate() > 0) {
|
||||
CharSequence date = DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, thread.getDate());
|
||||
dateView.setText(date);
|
||||
dateView.setTypeface(unreadCount == 0 ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
|
||||
dateView.setTextColor(unreadCount == 0 ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_date_color)
|
||||
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
|
||||
dateView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
|
||||
dateView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_date_color)
|
||||
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
|
||||
}
|
||||
|
||||
if (thread.isArchived()) {
|
||||
@@ -203,10 +212,10 @@ public class ConversationListItem extends RelativeLayout
|
||||
|
||||
setStatusIcons(thread);
|
||||
setThumbnailSnippet(thread);
|
||||
setBatchState(batchMode);
|
||||
setBatchMode(batchMode);
|
||||
setRippleColor(recipient.get());
|
||||
setUnreadIndicator(thread);
|
||||
this.contactPhotoImage.setAvatar(glideRequests, recipient.get(), true);
|
||||
this.contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode);
|
||||
}
|
||||
|
||||
public void bind(@NonNull Recipient contact,
|
||||
@@ -233,9 +242,9 @@ public class ConversationListItem extends RelativeLayout
|
||||
alertView.setNone();
|
||||
thumbnailView.setVisibility(GONE);
|
||||
|
||||
setBatchState(false);
|
||||
setBatchMode(false);
|
||||
setRippleColor(contact);
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient.get(), true);
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode);
|
||||
}
|
||||
|
||||
public void bind(@NonNull MessageResult messageResult,
|
||||
@@ -261,9 +270,9 @@ public class ConversationListItem extends RelativeLayout
|
||||
alertView.setNone();
|
||||
thumbnailView.setVisibility(GONE);
|
||||
|
||||
setBatchState(false);
|
||||
setBatchMode(false);
|
||||
setRippleColor(recipient.get());
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient.get(), true);
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -271,7 +280,9 @@ public class ConversationListItem extends RelativeLayout
|
||||
if (this.recipient != null) {
|
||||
this.recipient.removeForeverObserver(this);
|
||||
this.recipient = null;
|
||||
contactPhotoImage.setAvatar(glideRequests, null, true);
|
||||
|
||||
setBatchMode(false);
|
||||
contactPhotoImage.setAvatar(glideRequests, null, !batchMode);
|
||||
}
|
||||
|
||||
if (this.groupAddedBy != null) {
|
||||
@@ -280,8 +291,9 @@ public class ConversationListItem extends RelativeLayout
|
||||
}
|
||||
}
|
||||
|
||||
private void setBatchState(boolean batch) {
|
||||
setSelected(batch && selectedThreads.contains(threadId));
|
||||
private void setBatchMode(boolean batchMode) {
|
||||
this.batchMode = batchMode;
|
||||
setSelected(batchMode && selectedThreads.contains(thread.getThreadId()));
|
||||
}
|
||||
|
||||
public Recipient getRecipient() {
|
||||
@@ -292,6 +304,10 @@ public class ConversationListItem extends RelativeLayout
|
||||
return threadId;
|
||||
}
|
||||
|
||||
public @NonNull ThreadRecord getThread() {
|
||||
return thread;
|
||||
}
|
||||
|
||||
public int getUnreadCount() {
|
||||
return unreadCount;
|
||||
}
|
||||
@@ -304,7 +320,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
return lastSeen;
|
||||
}
|
||||
|
||||
private @NonNull CharSequence getTrimmedSnippet(@NonNull CharSequence snippet) {
|
||||
private static @NonNull CharSequence getTrimmedSnippet(@NonNull CharSequence snippet) {
|
||||
return snippet.length() <= MAX_SNIPPET_LENGTH ? snippet
|
||||
: snippet.subSequence(0, MAX_SNIPPET_LENGTH);
|
||||
}
|
||||
@@ -316,9 +332,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
|
||||
LayoutParams subjectParams = (RelativeLayout.LayoutParams)this.subjectContainer .getLayoutParams();
|
||||
subjectParams.addRule(RelativeLayout.LEFT_OF, R.id.thumbnail);
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
subjectParams.addRule(RelativeLayout.START_OF, R.id.thumbnail);
|
||||
}
|
||||
subjectParams.addRule(RelativeLayout.START_OF, R.id.thumbnail);
|
||||
this.subjectContainer.setLayoutParams(subjectParams);
|
||||
this.post(new ThumbnailPositioner(thumbnailView, archivedView, deliveryStatusIndicator, dateView));
|
||||
} else {
|
||||
@@ -326,9 +340,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
|
||||
LayoutParams subjectParams = (RelativeLayout.LayoutParams)this.subjectContainer.getLayoutParams();
|
||||
subjectParams.addRule(RelativeLayout.LEFT_OF, R.id.status);
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
subjectParams.addRule(RelativeLayout.START_OF, R.id.status);
|
||||
}
|
||||
subjectParams.addRule(RelativeLayout.START_OF, R.id.status);
|
||||
this.subjectContainer.setLayoutParams(subjectParams);
|
||||
}
|
||||
}
|
||||
@@ -361,22 +373,109 @@ public class ConversationListItem extends RelativeLayout
|
||||
}
|
||||
|
||||
private void setUnreadIndicator(ThreadRecord thread) {
|
||||
if (thread.isOutgoing() || thread.getUnreadCount() == 0) {
|
||||
if ((thread.isOutgoing() && !thread.isForcedUnread()) || thread.isRead()) {
|
||||
unreadIndicator.setVisibility(View.GONE);
|
||||
return;
|
||||
}
|
||||
|
||||
unreadIndicator.setText(String.valueOf(unreadCount));
|
||||
unreadIndicator.setText(unreadCount > 0 ? String.valueOf(unreadCount) : " ");
|
||||
unreadIndicator.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
fromView.setText(recipient, unreadCount == 0);
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient, true);
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient, !batchMode);
|
||||
setRippleColor(recipient);
|
||||
}
|
||||
|
||||
|
||||
private static SpannableString getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) {
|
||||
if (thread.getGroupAddedBy() != null) {
|
||||
return emphasisAdded(context.getString(thread.isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group
|
||||
: R.string.ThreadRecord_s_added_you_to_the_group,
|
||||
Recipient.live(thread.getGroupAddedBy()).get().getDisplayName(context)));
|
||||
} else if (!thread.isMessageRequestAccepted()) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_message_request));
|
||||
} else if (SmsDatabase.Types.isGroupUpdate(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
|
||||
} else if (SmsDatabase.Types.isGroupQuit(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group));
|
||||
} else if (SmsDatabase.Types.isKeyExchangeType(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ConversationListItem_key_exchange_message));
|
||||
} else if (SmsDatabase.Types.isFailedDecryptType(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message));
|
||||
} else if (SmsDatabase.Types.isNoRemoteSessionType(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session));
|
||||
} else if (SmsDatabase.Types.isEndSessionType(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_secure_session_reset));
|
||||
} else if (MmsSmsColumns.Types.isLegacyType(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
|
||||
} else if (MmsSmsColumns.Types.isDraftMessageType(thread.getType())) {
|
||||
String draftText = context.getString(R.string.ThreadRecord_draft);
|
||||
return emphasisAdded(draftText + " " + thread.getBody(), 0, draftText.length());
|
||||
} else if (SmsDatabase.Types.isOutgoingCall(thread.getType())) {
|
||||
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called));
|
||||
} else if (SmsDatabase.Types.isIncomingCall(thread.getType())) {
|
||||
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called_you));
|
||||
} else if (SmsDatabase.Types.isMissedCall(thread.getType())) {
|
||||
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_missed_call));
|
||||
} else if (SmsDatabase.Types.isJoinedType(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_s_is_on_signal, thread.getRecipient().toShortString(context)));
|
||||
} else if (SmsDatabase.Types.isExpirationTimerUpdate(thread.getType())) {
|
||||
int seconds = (int)(thread.getExpiresIn() / 1000);
|
||||
if (seconds <= 0) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_messages_disabled));
|
||||
}
|
||||
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_message_time_updated_to_s, time));
|
||||
} else if (SmsDatabase.Types.isIdentityUpdate(thread.getType())) {
|
||||
if (thread.getRecipient().isGroup()) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed));
|
||||
} else {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, thread.getRecipient().toShortString(context)));
|
||||
}
|
||||
} else if (SmsDatabase.Types.isIdentityVerified(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified));
|
||||
} else if (SmsDatabase.Types.isIdentityDefault(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_unverified));
|
||||
} else if (SmsDatabase.Types.isUnsupportedMessageType(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_message_could_not_be_processed));
|
||||
} else {
|
||||
ThreadDatabase.Extra extra = thread.getExtra();
|
||||
if (extra != null && extra.isViewOnce()) {
|
||||
return new SpannableString(emphasisAdded(getViewOnceDescription(context, thread.getContentType())));
|
||||
} else if (extra != null && extra.isRemoteDelete()) {
|
||||
return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_this_message_was_deleted)));
|
||||
} else {
|
||||
return new SpannableString(Util.emptyIfNull(thread.getBody()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull SpannableString emphasisAdded(String sequence) {
|
||||
return emphasisAdded(sequence, 0, sequence.length());
|
||||
}
|
||||
|
||||
private static @NonNull SpannableString emphasisAdded(String sequence, int start, int end) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC),
|
||||
start,
|
||||
end,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
private static String getViewOnceDescription(@NonNull Context context, @Nullable String contentType) {
|
||||
if (MediaUtil.isViewOnceType(contentType)) {
|
||||
return context.getString(R.string.ThreadRecord_view_once_media);
|
||||
} else if (MediaUtil.isVideoType(contentType)) {
|
||||
return context.getString(R.string.ThreadRecord_view_once_video);
|
||||
} else {
|
||||
return context.getString(R.string.ThreadRecord_view_once_photo);
|
||||
}
|
||||
}
|
||||
|
||||
private static class ThumbnailPositioner implements Runnable {
|
||||
|
||||
private final View thumbnailView;
|
||||
@@ -399,14 +498,10 @@ public class ConversationListItem extends RelativeLayout
|
||||
(archivedView.getWidth() + deliveryStatusView.getWidth()) > dateView.getWidth())
|
||||
{
|
||||
thumbnailParams.addRule(RelativeLayout.LEFT_OF, R.id.status);
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
thumbnailParams.addRule(RelativeLayout.START_OF, R.id.status);
|
||||
}
|
||||
thumbnailParams.addRule(RelativeLayout.START_OF, R.id.status);
|
||||
} else {
|
||||
thumbnailParams.addRule(RelativeLayout.LEFT_OF, R.id.date);
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
thumbnailParams.addRule(RelativeLayout.START_OF, R.id.date);
|
||||
}
|
||||
thumbnailParams.addRule(RelativeLayout.START_OF, R.id.date);
|
||||
}
|
||||
|
||||
thumbnailView.setLayoutParams(thumbnailParams);
|
||||
|
||||
@@ -119,7 +119,7 @@ public class MasterCipher {
|
||||
|
||||
return encryptedAndMacBody;
|
||||
} catch (GeneralSecurityException ge) {
|
||||
Log.w("bodycipher", ge);
|
||||
Log.w(TAG, "bodycipher", ge);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public class MasterSecretUtil {
|
||||
|
||||
private static final String TAG = Log.tag(MasterSecretUtil.class);
|
||||
|
||||
public static final String UNENCRYPTED_PASSPHRASE = "unencrypted";
|
||||
public static final String PREFERENCES_NAME = "SecureSMS-Preferences";
|
||||
|
||||
@@ -115,10 +117,10 @@ public class MasterSecretUtil {
|
||||
return new MasterSecret(new SecretKeySpec(encryptionSecret, "AES"),
|
||||
new SecretKeySpec(macSecret, "HmacSHA1"));
|
||||
} catch (GeneralSecurityException e) {
|
||||
Log.w("keyutil", e);
|
||||
Log.w(TAG, e);
|
||||
return null; //XXX
|
||||
} catch (IOException e) {
|
||||
Log.w("keyutil", e);
|
||||
Log.w(TAG, e);
|
||||
return null; //XXX
|
||||
}
|
||||
}
|
||||
@@ -183,7 +185,7 @@ public class MasterSecretUtil {
|
||||
return new MasterSecret(new SecretKeySpec(encryptionSecret, "AES"),
|
||||
new SecretKeySpec(macSecret, "HmacSHA1"));
|
||||
} catch (GeneralSecurityException e) {
|
||||
Log.w("keyutil", e);
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -249,7 +251,7 @@ public class MasterSecretUtil {
|
||||
SecretKey key = generator.generateKey();
|
||||
return key.getEncoded();
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
Log.w("keyutil", ex);
|
||||
Log.w(TAG, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -259,7 +261,7 @@ public class MasterSecretUtil {
|
||||
KeyGenerator generator = KeyGenerator.getInstance("HmacSHA1");
|
||||
return generator.generateKey().getEncoded();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
Log.w("keyutil", e);
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -290,10 +292,10 @@ public class MasterSecretUtil {
|
||||
if (scaledIterationTarget < MINIMUM_ITERATION_COUNT) return MINIMUM_ITERATION_COUNT;
|
||||
else return scaledIterationTarget;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
Log.w("MasterSecretUtil", e);
|
||||
Log.w(TAG, e);
|
||||
return MINIMUM_ITERATION_COUNT;
|
||||
} catch (InvalidKeySpecException e) {
|
||||
Log.w("MasterSecretUtil", e);
|
||||
Log.w(TAG, e);
|
||||
return MINIMUM_ITERATION_COUNT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
@@ -53,6 +54,18 @@ public final class ProfileKeyUtil {
|
||||
return null;
|
||||
}
|
||||
|
||||
public static @Nullable ProfileKeyCredential profileKeyCredentialOrNull(@Nullable byte[] profileKeyCredential) {
|
||||
if (profileKeyCredential != null) {
|
||||
try {
|
||||
return new ProfileKeyCredential(profileKeyCredential);
|
||||
} catch (InvalidInputException e) {
|
||||
Log.w(TAG, String.format(Locale.US, "Seen non-null profile key credential of wrong length %d", profileKeyCredential.length), e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static @NonNull ProfileKey profileKeyOrThrow(@NonNull byte[] profileKey) {
|
||||
try {
|
||||
return new ProfileKey(profileKey);
|
||||
@@ -69,6 +82,10 @@ public final class ProfileKeyUtil {
|
||||
return Optional.of(profileKeyOrThrow(profileKey));
|
||||
}
|
||||
|
||||
public static @NonNull Optional<ProfileKeyCredential> profileKeyCredentialOptional(@Nullable byte[] profileKey) {
|
||||
return Optional.fromNullable(profileKeyCredentialOrNull(profileKey));
|
||||
}
|
||||
|
||||
public static @NonNull ProfileKey createNew() {
|
||||
try {
|
||||
return new ProfileKey(Util.getSecretBytes(32));
|
||||
|
||||
@@ -88,7 +88,7 @@ public class PublicKey {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
return md.digest(serialize());
|
||||
} catch (NoSuchAlgorithmException nsae) {
|
||||
Log.w("LocalKeyPair", nsae);
|
||||
Log.w(TAG, "LocalKeyPair", nsae);
|
||||
throw new IllegalArgumentException("SHA-1 isn't supported!");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ public class UnidentifiedAccessUtil {
|
||||
try {
|
||||
byte[] theirUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipient);
|
||||
byte[] ourUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey());
|
||||
byte[] ourUnidentifiedAccessCertificate = recipient.resolve().isUuidSupported() && Recipient.self().isUuidSupported()
|
||||
? TextSecurePreferences.getUnidentifiedAccessCertificate(context)
|
||||
: TextSecurePreferences.getUnidentifiedAccessCertificateLegacy(context);
|
||||
byte[] ourUnidentifiedAccessCertificate = TextSecurePreferences.getUnidentifiedAccessCertificate(context);
|
||||
|
||||
if (TextSecurePreferences.isUniversalUnidentifiedAccess(context)) {
|
||||
ourUnidentifiedAccessKey = Util.getSecretBytes(16);
|
||||
@@ -77,8 +75,7 @@ public class UnidentifiedAccessUtil {
|
||||
public static Optional<UnidentifiedAccessPair> getAccessForSync(@NonNull Context context) {
|
||||
try {
|
||||
byte[] ourUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey());
|
||||
byte[] ourUnidentifiedAccessCertificate = Recipient.self().isUuidSupported() ? TextSecurePreferences.getUnidentifiedAccessCertificate(context)
|
||||
: TextSecurePreferences.getUnidentifiedAccessCertificateLegacy(context);
|
||||
byte[] ourUnidentifiedAccessCertificate = TextSecurePreferences.getUnidentifiedAccessCertificate(context);
|
||||
|
||||
if (TextSecurePreferences.isUniversalUnidentifiedAccess(context)) {
|
||||
ourUnidentifiedAccessKey = Util.getSecretBytes(16);
|
||||
|
||||
@@ -69,7 +69,7 @@ public class TextSecureIdentityKeyStore implements IdentityKeyStore {
|
||||
}
|
||||
|
||||
identityDatabase.saveIdentity(recipient.getId(), identityKey, verifiedStatus, false, System.currentTimeMillis(), nonBlockingApproval);
|
||||
IdentityUtil.markIdentityUpdate(context, recipient);
|
||||
IdentityUtil.markIdentityUpdate(context, recipient.getId());
|
||||
SessionUtil.archiveSiblingSessions(context, address);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -31,12 +31,12 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import net.sqlcipher.DatabaseUtils;
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.json.JSONArray;
|
||||
@@ -44,12 +44,14 @@ import org.json.JSONException;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.MediaStream;
|
||||
import org.thoughtcrime.securesms.mms.MmsException;
|
||||
@@ -82,6 +84,7 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
@@ -120,7 +123,7 @@ public class AttachmentDatabase extends Database {
|
||||
static final String HEIGHT = "height";
|
||||
static final String CAPTION = "caption";
|
||||
private static final String DATA_HASH = "data_hash";
|
||||
static final String BLUR_HASH = "blur_hash";
|
||||
static final String VISUAL_HASH = "blur_hash";
|
||||
static final String TRANSFORM_PROPERTIES = "transform_properties";
|
||||
static final String DISPLAY_ORDER = "display_order";
|
||||
static final String UPLOAD_TIMESTAMP = "upload_timestamp";
|
||||
@@ -145,7 +148,7 @@ public class AttachmentDatabase extends Database {
|
||||
THUMBNAIL_ASPECT_RATIO, UNIQUE_ID, DIGEST,
|
||||
FAST_PREFLIGHT_ID, VOICE_NOTE, QUOTE, DATA_RANDOM,
|
||||
THUMBNAIL_RANDOM, WIDTH, HEIGHT, CAPTION, STICKER_PACK_ID,
|
||||
STICKER_PACK_KEY, STICKER_ID, DATA_HASH, BLUR_HASH,
|
||||
STICKER_PACK_KEY, STICKER_ID, DATA_HASH, VISUAL_HASH,
|
||||
TRANSFORM_PROPERTIES, TRANSFER_FILE, DISPLAY_ORDER,
|
||||
UPLOAD_TIMESTAMP };
|
||||
|
||||
@@ -182,7 +185,7 @@ public class AttachmentDatabase extends Database {
|
||||
STICKER_PACK_KEY + " DEFAULT NULL, " +
|
||||
STICKER_ID + " INTEGER DEFAULT -1, " +
|
||||
DATA_HASH + " TEXT DEFAULT NULL, " +
|
||||
BLUR_HASH + " TEXT DEFAULT NULL, " +
|
||||
VISUAL_HASH + " TEXT DEFAULT NULL, " +
|
||||
TRANSFORM_PROPERTIES + " TEXT DEFAULT NULL, " +
|
||||
TRANSFER_FILE + " TEXT DEFAULT NULL, " +
|
||||
DISPLAY_ORDER + " INTEGER DEFAULT 0, " +
|
||||
@@ -417,7 +420,7 @@ public class AttachmentDatabase extends Database {
|
||||
values.put(WIDTH, 0);
|
||||
values.put(HEIGHT, 0);
|
||||
values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE);
|
||||
values.put(BLUR_HASH, (String) null);
|
||||
values.put(VISUAL_HASH, (String) null);
|
||||
values.put(CONTENT_TYPE, MediaUtil.VIEW_ONCE);
|
||||
|
||||
database.update(TABLE_NAME, values, MMS_ID + " = ?", new String[] {mmsId + ""});
|
||||
@@ -466,26 +469,58 @@ public class AttachmentDatabase extends Database {
|
||||
notifyAttachmentListeners();
|
||||
}
|
||||
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
private void deleteAttachmentOnDisk(@Nullable String data,
|
||||
@Nullable String thumbnail,
|
||||
@Nullable String contentType,
|
||||
@NonNull AttachmentId attachmentId)
|
||||
{
|
||||
boolean dataInUse = isDataUsedByAnotherAttachment(data, attachmentId);
|
||||
DataUsageResult dataUsage = getAttachmentFileUsages(data, attachmentId);
|
||||
|
||||
if (dataInUse) {
|
||||
if (dataUsage.hasStrongReference()) {
|
||||
Log.i(TAG, "[deleteAttachmentOnDisk] Attachment in use. Skipping deletion. " + data + " " + attachmentId);
|
||||
} else {
|
||||
Log.i(TAG, "[deleteAttachmentOnDisk] No other users of this attachment. Safe to delete. " + data + " " + attachmentId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(data) && !dataInUse) {
|
||||
new File(data).delete();
|
||||
Log.i(TAG, "[deleteAttachmentOnDisk] No other strong uses of this attachment. Safe to delete. " + data + " " + attachmentId);
|
||||
|
||||
if (!TextUtils.isEmpty(data)) {
|
||||
if (new File(data).delete()) {
|
||||
Log.i(TAG, "[deleteAttachmentOnDisk] Deleted attachment file. " + data + " " + attachmentId);
|
||||
|
||||
List<AttachmentId> removableWeakReferences = dataUsage.getRemovableWeakReferences();
|
||||
|
||||
if (removableWeakReferences.size() > 0) {
|
||||
Log.i(TAG, String.format(Locale.US, "[deleteAttachmentOnDisk] Deleting %d weak references for %s", removableWeakReferences.size(), data));
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
int deletedCount = 0;
|
||||
database.beginTransaction();
|
||||
try {
|
||||
for (AttachmentId weakReference : removableWeakReferences) {
|
||||
Log.i(TAG, String.format("[deleteAttachmentOnDisk] Deleting weak reference for %s %s", data, weakReference));
|
||||
deletedCount += database.delete(TABLE_NAME, PART_ID_WHERE, weakReference.toStrings());
|
||||
}
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
String logMessage = String.format(Locale.US, "[deleteAttachmentOnDisk] Deleted %d/%d weak references for %s", deletedCount, removableWeakReferences.size(), data);
|
||||
if (deletedCount != removableWeakReferences.size()) {
|
||||
Log.w(TAG, logMessage);
|
||||
} else {
|
||||
Log.i(TAG, logMessage);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "[deleteAttachmentOnDisk] Failed to delete attachment. " + data + " " + attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(thumbnail)) {
|
||||
new File(thumbnail).delete();
|
||||
if (new File(thumbnail).delete()) {
|
||||
Log.i(TAG, "[deleteAttachmentOnDisk] Deleted thumbnail. " + data + " " + attachmentId);
|
||||
} else {
|
||||
Log.w(TAG, "[deleteAttachmentOnDisk] Failed to delete attachment. " + data + " " + attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (MediaUtil.isImageType(contentType) || thumbnail != null) {
|
||||
@@ -493,17 +528,26 @@ public class AttachmentDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDataUsedByAnotherAttachment(@Nullable String data, @NonNull AttachmentId attachmentId) {
|
||||
if (data == null) return false;
|
||||
private @NonNull DataUsageResult getAttachmentFileUsages(@Nullable String data, @NonNull AttachmentId attachmentId) {
|
||||
if (data == null) return DataUsageResult.NOT_IN_USE;
|
||||
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
long matches = DatabaseUtils.longForQuery(database,
|
||||
"SELECT count(*) FROM " + TABLE_NAME + " WHERE " + DATA + " = ? AND " + UNIQUE_ID + " != ? AND " + ROW_ID + " != ?;",
|
||||
new String[]{data,
|
||||
Long.toString(attachmentId.getUniqueId()),
|
||||
Long.toString(attachmentId.getRowId())});
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
String selection = DATA + " = ? AND " + UNIQUE_ID + " != ? AND " + ROW_ID + " != ?";
|
||||
String[] args = {data, Long.toString(attachmentId.getUniqueId()), Long.toString(attachmentId.getRowId())};
|
||||
List<AttachmentId> quoteRows = new LinkedList<>();
|
||||
|
||||
return matches != 0;
|
||||
try (Cursor cursor = database.query(TABLE_NAME, new String[]{ROW_ID, UNIQUE_ID, QUOTE}, selection, args, null, null, null, null)) {
|
||||
while (cursor.moveToNext()) {
|
||||
boolean isQuote = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE)) == 1;
|
||||
if (isQuote) {
|
||||
quoteRows.add(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)), cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID))));
|
||||
} else {
|
||||
return DataUsageResult.IN_USE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new DataUsageResult(quoteRows);
|
||||
}
|
||||
|
||||
public void insertAttachmentsForPlaceholder(long mmsId, @NonNull AttachmentId attachmentId, @NonNull InputStream inputStream)
|
||||
@@ -530,19 +574,16 @@ public class AttachmentDatabase extends Database {
|
||||
values.put(DATA_HASH, dataInfo.hash);
|
||||
}
|
||||
|
||||
if (placeholder != null && placeholder.getBlurHash() != null) {
|
||||
values.put(BLUR_HASH, placeholder.getBlurHash().getHash());
|
||||
String visualHashString = getVisualHashStringOrNull(placeholder);
|
||||
if (visualHashString != null) {
|
||||
values.put(VISUAL_HASH, visualHashString);
|
||||
}
|
||||
|
||||
values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE);
|
||||
values.put(CDN_NUMBER, 0);
|
||||
values.put(CONTENT_LOCATION, (String)null);
|
||||
values.put(CONTENT_DISPOSITION, (String)null);
|
||||
values.put(DIGEST, (byte[])null);
|
||||
values.put(NAME, (String) null);
|
||||
values.put(FAST_PREFLIGHT_ID, (String)null);
|
||||
values.put(TRANSFER_FILE, (String)null);
|
||||
|
||||
values.put(TRANSFORM_PROPERTIES, TransformProperties.forSkipTransform().serialize());
|
||||
|
||||
if (database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()) == 0) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
dataInfo.file.delete();
|
||||
@@ -559,9 +600,11 @@ public class AttachmentDatabase extends Database {
|
||||
thumbnailExecutor.submit(new ThumbnailFetchCallable(attachmentId, STANDARD_THUMB_TIME));
|
||||
}
|
||||
|
||||
private static @Nullable String getBlurHashStringOrNull(@Nullable BlurHash blurHash) {
|
||||
if (blurHash == null) return null;
|
||||
return blurHash.getHash();
|
||||
private static @Nullable String getVisualHashStringOrNull(@Nullable Attachment attachment) {
|
||||
if (attachment == null) return null;
|
||||
else if (attachment.getBlurHash() != null) return attachment.getBlurHash().getHash();
|
||||
else if (attachment.getAudioHash() != null) return attachment.getAudioHash().getHash();
|
||||
else return null;
|
||||
}
|
||||
|
||||
public void copyAttachmentData(@NonNull AttachmentId sourceId, @NonNull AttachmentId destinationId)
|
||||
@@ -598,7 +641,7 @@ public class AttachmentDatabase extends Database {
|
||||
contentValues.put(WIDTH, sourceAttachment.getWidth());
|
||||
contentValues.put(HEIGHT, sourceAttachment.getHeight());
|
||||
contentValues.put(CONTENT_TYPE, sourceAttachment.getContentType());
|
||||
contentValues.put(BLUR_HASH, getBlurHashStringOrNull(sourceAttachment.getBlurHash()));
|
||||
contentValues.put(VISUAL_HASH, getVisualHashStringOrNull(sourceAttachment));
|
||||
|
||||
database.update(TABLE_NAME, contentValues, PART_ID_WHERE, destinationId.toStrings());
|
||||
}
|
||||
@@ -642,7 +685,7 @@ public class AttachmentDatabase extends Database {
|
||||
values.put(NAME, attachment.getRelay());
|
||||
values.put(SIZE, attachment.getSize());
|
||||
values.put(FAST_PREFLIGHT_ID, attachment.getFastPreflightId());
|
||||
values.put(BLUR_HASH, getBlurHashStringOrNull(attachment.getBlurHash()));
|
||||
values.put(VISUAL_HASH, getVisualHashStringOrNull(attachment));
|
||||
values.put(UPLOAD_TIMESTAMP, uploadTimestamp);
|
||||
|
||||
if (dataInfo != null && dataInfo.hash != null) {
|
||||
@@ -1103,11 +1146,12 @@ public class AttachmentDatabase extends Database {
|
||||
JsonUtils.SaneJSONObject object = new JsonUtils.SaneJSONObject(array.getJSONObject(i));
|
||||
|
||||
if (!object.isNull(ROW_ID)) {
|
||||
String contentType = object.getString(CONTENT_TYPE);
|
||||
result.add(new DatabaseAttachment(new AttachmentId(object.getLong(ROW_ID), object.getLong(UNIQUE_ID)),
|
||||
object.getLong(MMS_ID),
|
||||
!TextUtils.isEmpty(object.getString(DATA)),
|
||||
!TextUtils.isEmpty(object.getString(THUMBNAIL)),
|
||||
object.getString(CONTENT_TYPE),
|
||||
contentType,
|
||||
object.getInt(TRANSFER_STATE),
|
||||
object.getLong(SIZE),
|
||||
object.getString(FILE_NAME),
|
||||
@@ -1127,7 +1171,8 @@ public class AttachmentDatabase extends Database {
|
||||
object.getString(STICKER_PACK_KEY),
|
||||
object.getInt(STICKER_ID))
|
||||
: null,
|
||||
BlurHash.parseOrNull(object.getString(BLUR_HASH)),
|
||||
MediaUtil.isAudioType(contentType) ? null : BlurHash.parseOrNull(object.getString(VISUAL_HASH)),
|
||||
MediaUtil.isAudioType(contentType) ? AudioHash.parseOrNull(object.getString(VISUAL_HASH)) : null,
|
||||
TransformProperties.parse(object.getString(TRANSFORM_PROPERTIES)),
|
||||
object.getInt(DISPLAY_ORDER),
|
||||
object.getLong(UPLOAD_TIMESTAMP)));
|
||||
@@ -1136,12 +1181,13 @@ public class AttachmentDatabase extends Database {
|
||||
|
||||
return result;
|
||||
} else {
|
||||
String contentType = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_TYPE));
|
||||
return Collections.singletonList(new DatabaseAttachment(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID))),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(MMS_ID)),
|
||||
!cursor.isNull(cursor.getColumnIndexOrThrow(DATA)),
|
||||
!cursor.isNull(cursor.getColumnIndexOrThrow(THUMBNAIL)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_TYPE)),
|
||||
contentType,
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(TRANSFER_STATE)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(SIZE)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(FILE_NAME)),
|
||||
@@ -1161,7 +1207,8 @@ public class AttachmentDatabase extends Database {
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(STICKER_PACK_KEY)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(STICKER_ID)))
|
||||
: null,
|
||||
BlurHash.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(BLUR_HASH))),
|
||||
MediaUtil.isAudioType(contentType) ? null : BlurHash.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(VISUAL_HASH))),
|
||||
MediaUtil.isAudioType(contentType) ? AudioHash.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(VISUAL_HASH))) : null,
|
||||
TransformProperties.parse(cursor.getString(cursor.getColumnIndexOrThrow(TRANSFORM_PROPERTIES))),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DISPLAY_ORDER)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(UPLOAD_TIMESTAMP))));
|
||||
@@ -1171,7 +1218,6 @@ public class AttachmentDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private AttachmentId insertAttachment(long mmsId, Attachment attachment, boolean quote)
|
||||
throws MmsException
|
||||
{
|
||||
@@ -1198,7 +1244,10 @@ public class AttachmentDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
boolean useTemplateUpload = template.getUploadTimestamp() > attachment.getUploadTimestamp() && template.getTransferState() == TRANSFER_PROGRESS_DONE;
|
||||
boolean useTemplateUpload = template.getUploadTimestamp() > attachment.getUploadTimestamp() &&
|
||||
template.getTransferState() == TRANSFER_PROGRESS_DONE &&
|
||||
template.getTransformProperties().shouldSkipTransform() &&
|
||||
!attachment.getTransformProperties().isVideoEdited();
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(MMS_ID, mmsId);
|
||||
@@ -1220,11 +1269,11 @@ public class AttachmentDatabase extends Database {
|
||||
contentValues.put(CAPTION, attachment.getCaption());
|
||||
contentValues.put(UPLOAD_TIMESTAMP, useTemplateUpload ? template.getUploadTimestamp() : attachment.getUploadTimestamp());
|
||||
if (attachment.getTransformProperties().isVideoEdited()) {
|
||||
contentValues.putNull(BLUR_HASH);
|
||||
contentValues.putNull(VISUAL_HASH);
|
||||
contentValues.put(TRANSFORM_PROPERTIES, attachment.getTransformProperties().serialize());
|
||||
thumbnailTimeUs = Math.max(STANDARD_THUMB_TIME, attachment.getTransformProperties().videoTrimStartTimeUs);
|
||||
} else {
|
||||
contentValues.put(BLUR_HASH, getBlurHashStringOrNull(template.getBlurHash()));
|
||||
contentValues.put(VISUAL_HASH, getVisualHashStringOrNull(template));
|
||||
contentValues.put(TRANSFORM_PROPERTIES, template.getTransformProperties().serialize());
|
||||
thumbnailTimeUs = STANDARD_THUMB_TIME;
|
||||
}
|
||||
@@ -1331,6 +1380,21 @@ public class AttachmentDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public void writeAudioHash(@NonNull AttachmentId attachmentId, @Nullable AudioWaveFormData audioWaveForm) {
|
||||
Log.i(TAG, "updating part audio wave form for #" + attachmentId);
|
||||
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues(1);
|
||||
|
||||
if (audioWaveForm != null) {
|
||||
values.put(VISUAL_HASH, new AudioHash(audioWaveForm).getHash());
|
||||
} else {
|
||||
values.putNull(VISUAL_HASH);
|
||||
}
|
||||
|
||||
database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings());
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
class ThumbnailFetchCallable implements Callable<InputStream> {
|
||||
@@ -1419,6 +1483,39 @@ public class AttachmentDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DataUsageResult {
|
||||
private final boolean hasStrongReference;
|
||||
private final List<AttachmentId> removableWeakReferences;
|
||||
|
||||
private static final DataUsageResult IN_USE = new DataUsageResult(true, Collections.emptyList());
|
||||
private static final DataUsageResult NOT_IN_USE = new DataUsageResult(false, Collections.emptyList());
|
||||
|
||||
DataUsageResult(@NonNull List<AttachmentId> removableWeakReferences) {
|
||||
this(false, removableWeakReferences);
|
||||
}
|
||||
|
||||
private DataUsageResult(boolean hasStrongReference, @NonNull List<AttachmentId> removableWeakReferences) {
|
||||
if (hasStrongReference && removableWeakReferences.size() > 0) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
this.hasStrongReference = hasStrongReference;
|
||||
this.removableWeakReferences = removableWeakReferences;
|
||||
}
|
||||
|
||||
boolean hasStrongReference() {
|
||||
return hasStrongReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries in here can be removed from the database.
|
||||
* <p>
|
||||
* Only possible to be non-empty when {@link #hasStrongReference} is false.
|
||||
*/
|
||||
@NonNull List<AttachmentId> getRemovableWeakReferences() {
|
||||
return removableWeakReferences;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class TransformProperties {
|
||||
|
||||
@JsonProperty private final boolean skipTransform;
|
||||
|
||||
@@ -31,6 +31,8 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* RecyclerView.Adapter that manages a Cursor, comparable to the CursorAdapter usable in ListView/GridView.
|
||||
*/
|
||||
@@ -173,6 +175,16 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
|
||||
|
||||
public abstract VH onCreateItemViewHolder(ViewGroup parent, int viewType);
|
||||
|
||||
@Override
|
||||
public final void onBindViewHolder(@NonNull ViewHolder viewHolder, int position, @NonNull List<Object> payloads) {
|
||||
if (arePayloadsValid(payloads) && !isHeaderPosition(position) && !isFooterPosition(position)) {
|
||||
if (isFastAccessPosition(position)) onBindFastAccessItemViewHolder((VH)viewHolder, position, payloads);
|
||||
else onBindItemViewHolder((VH)viewHolder, getCursorAtPositionOrThrow(position), payloads);
|
||||
} else {
|
||||
onBindViewHolder(viewHolder, position);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public final void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||
@@ -188,8 +200,17 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
|
||||
|
||||
public abstract void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor);
|
||||
|
||||
protected void onBindFastAccessItemViewHolder(VH viewHolder, int position) {
|
||||
protected boolean arePayloadsValid(@NonNull List<Object> payloads) {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor, @NonNull List<Object> payloads) {
|
||||
}
|
||||
|
||||
protected void onBindFastAccessItemViewHolder(VH viewHolder, int position) {
|
||||
}
|
||||
|
||||
protected void onBindFastAccessItemViewHolder(VH viewHolder, int position, @NonNull List<Object> payloads) {
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user